mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28113f5fdc | |||
| 42b8805e94 | |||
| 51a64f41b9 | |||
| a7bd9a942c | |||
| 97491a6175 | |||
| ea5cb3a130 | |||
| 921010926b | |||
| 7425c3d57b | |||
| 1b7d4eef46 | |||
| c30dab56a3 | |||
| e365250440 | |||
| 47d10e1f58 | |||
| e6473300a7 | |||
| 994f64f753 | |||
| 90064a5480 | |||
| 6e8eac9887 | |||
| d3051eaffe | |||
| 496a897782 | |||
| 22b7290ee1 | |||
| 14037d5dea | |||
| 152db3fb9f | |||
| 0413d123da | |||
| 5f6bd7b5cd | |||
| 3c25bb5d61 | |||
| 659b91b000 | |||
| fe5b3bd56a | |||
| 82bbb116ae | |||
| 9f5ff7e6f0 | |||
| a00504080a | |||
| fc6e2e6f10 | |||
| 65c5f3e4bf | |||
| 07e340251b | |||
| 9b956b928b | |||
| c3187f6e3f | |||
| 60c815a8b3 | |||
| 41b155ea31 | |||
| 2888ae8bf7 | |||
| 7363064d89 | |||
| 1d53bf2ae1 |
@@ -27,6 +27,9 @@ permissions:
|
||||
jobs:
|
||||
version-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump_version.outputs.new_version }}
|
||||
tag: ${{ steps.bump_version.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -149,3 +152,117 @@ jobs:
|
||||
VERSION=${{ steps.bump_version.outputs.new_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
|
||||
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
- name: Upload web/dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
retention-days: 1
|
||||
|
||||
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
|
||||
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
|
||||
publish-create-release:
|
||||
needs: [version-and-publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
run: |
|
||||
set -eux
|
||||
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--verify-tag --latest || true
|
||||
|
||||
build-binaries:
|
||||
needs: [version-and-publish, build-frontend, publish-create-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
ext: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
ext: .exe
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
- name: Download web/dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
|
||||
-o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p package/mmtl
|
||||
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
|
||||
cp README.md package/mmtl/ 2>/dev/null || true
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
(cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl)
|
||||
else
|
||||
tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl
|
||||
fi
|
||||
- name: Upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
run: |
|
||||
set -eux
|
||||
PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
||||
TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
|
||||
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
|
||||
if [ -f "$PKG" ]; then
|
||||
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
|
||||
fi
|
||||
if [ -f "$TAR" ]; then
|
||||
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
|
||||
fi
|
||||
|
||||
@@ -18,6 +18,19 @@ jobs:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
||||
# before the Go toolchain touches the `web` package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
@@ -360,15 +360,17 @@ docker compose -f docker-compose.search.yml up -d --no-deps mmtl
|
||||
|
||||
本地开发需要 Go、Node.js 和 npm。
|
||||
|
||||
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
|
||||
|
||||
```bash
|
||||
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
|
||||
npm --prefix web ci
|
||||
npm --prefix web run build
|
||||
|
||||
# 后端测试
|
||||
go test ./...
|
||||
|
||||
# 前端依赖与构建
|
||||
npm --prefix web install
|
||||
npm --prefix web run build
|
||||
|
||||
# 本地运行后端
|
||||
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
|
||||
go run ./cmd/server
|
||||
|
||||
# 本地运行前端开发服务器
|
||||
@@ -387,6 +389,21 @@ http://127.0.0.1:3000
|
||||
http://127.0.0.1:8080/api/health
|
||||
```
|
||||
|
||||
### 交叉编译单文件发布物
|
||||
|
||||
CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。
|
||||
|
||||
本地手动交叉编译某个平台:
|
||||
|
||||
```bash
|
||||
# 先构建前端
|
||||
npm --prefix web ci && npm --prefix web run build
|
||||
|
||||
# 例如:构建 Linux amd64 单文件
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server
|
||||
```
|
||||
|
||||
## 贡献与反馈
|
||||
|
||||
提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, webDir)
|
||||
serveSPA(router, os.DirFS(webDir))
|
||||
|
||||
for _, path := range []string{"/", "/login", "/library/e1c3507e-2878-40ae-a0e1-6b6e44b7fa7a", "/media/abc"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
@@ -93,7 +93,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, webDir)
|
||||
serveSPA(router, os.DirFS(webDir))
|
||||
|
||||
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
assetResp := httptest.NewRecorder()
|
||||
@@ -155,7 +155,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
serveSPA(router, t.TempDir())
|
||||
serveSPA(router, os.DirFS(t.TempDir()))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
+59
-23
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,6 +15,8 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/handler"
|
||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
|
||||
"github.com/ShukeBta/MMTL/web"
|
||||
)
|
||||
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
@@ -29,31 +33,41 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
|
||||
|
||||
handler.Register(r, cfg, logger, svc)
|
||||
|
||||
if cfg.App.WebDir != "" {
|
||||
serveSPA(r, cfg.App.WebDir)
|
||||
// Prefer a directory on disk when configured explicitly (e.g. the Docker image
|
||||
// mounts web/dist from the build stage, or an operator overrides app.web_dir
|
||||
// with a custom skin). Otherwise fall back to the SPA embedded into the binary,
|
||||
// which is what makes the cross-platform single-file artifacts work.
|
||||
uiFS := webui.DistFS()
|
||||
if dir := cfg.App.WebDir; dir != "" {
|
||||
disk := os.DirFS(dir)
|
||||
if index, err := fs.Stat(disk, "index.html"); err == nil && !index.IsDir() {
|
||||
uiFS = disk
|
||||
}
|
||||
}
|
||||
serveSPA(r, uiFS)
|
||||
return r
|
||||
}
|
||||
|
||||
// serveSPA serves the React build artifacts and falls back to index.html for
|
||||
// non-API, non-asset paths so client-side routing keeps working.
|
||||
func serveSPA(r *gin.Engine, webDir string) {
|
||||
// non-API, non-asset paths so client-side routing keeps working. The UI tree
|
||||
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
|
||||
func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
assets := r.Group("/assets")
|
||||
assets.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Next()
|
||||
})
|
||||
assets.Static("/", filepath.Join(webDir, "assets"))
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
c.Next()
|
||||
})
|
||||
brand.Static("/", filepath.Join(webDir, "brand"))
|
||||
brand.GET("/*filepath", serveFSDir(root, "brand"))
|
||||
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
|
||||
filePath := filepath.Join(webDir, strings.TrimPrefix(rootFile, "/"))
|
||||
r.GET(rootFile, serveNoCacheFile(filePath))
|
||||
r.HEAD(rootFile, serveNoCacheFile(filePath))
|
||||
name := strings.TrimPrefix(rootFile, "/")
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
@@ -61,28 +75,50 @@ func serveSPA(r *gin.Engine, webDir string) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
serveSPAIndex(c, filepath.Join(webDir, "index.html"))
|
||||
setNoCacheHeaders(c)
|
||||
data, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "MMTL web UI not found")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
})
|
||||
}
|
||||
|
||||
func serveNoCacheFile(filePath string) gin.HandlerFunc {
|
||||
// serveFSDir serves a static subdirectory of root. A missing asset returns 404.
|
||||
func serveFSDir(root fs.FS, dir string) gin.HandlerFunc {
|
||||
sub, err := fs.Sub(root, dir)
|
||||
if err != nil {
|
||||
return func(c *gin.Context) { c.Status(http.StatusNotFound) }
|
||||
}
|
||||
handler := http.StripPrefix("/"+dir, http.FileServerFS(sub))
|
||||
return func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.File(filePath)
|
||||
handler.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func serveSPAIndex(c *gin.Context, indexPath string) {
|
||||
setNoCacheHeaders(c)
|
||||
if _, err := os.Stat(indexPath); err != nil {
|
||||
c.String(http.StatusNotFound, "MMTL web UI not found: %s", indexPath)
|
||||
return
|
||||
// serveFSFile serves a single root-level file (favicon / service worker) with
|
||||
// no-cache headers. It reads from root, which may be the embedded SPA or disk.
|
||||
func serveFSFile(root fs.FS, name string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
data, err := fs.ReadFile(root, name)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, mimeTypeByName(name), data)
|
||||
}
|
||||
}
|
||||
|
||||
// mimeTypeByName returns an HTTP content type guessed from a file extension.
|
||||
func mimeTypeByName(name string) string {
|
||||
switch mime.TypeByExtension(filepath.Ext(name)) {
|
||||
case "":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return mime.TypeByExtension(filepath.Ext(name))
|
||||
}
|
||||
c.File(indexPath)
|
||||
}
|
||||
|
||||
func setNoCacheHeaders(c *gin.Context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/ShukeBta/MMTL
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
@@ -16,6 +17,7 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/sys v0.20.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
@@ -66,13 +68,13 @@ require (
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -150,6 +152,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
||||
@@ -3,8 +3,8 @@ package config
|
||||
import "github.com/spf13/viper"
|
||||
|
||||
const (
|
||||
defaultDatabaseMaxOpenConns = 4
|
||||
defaultDatabaseMaxIdleConns = 2
|
||||
defaultDatabaseMaxOpenConns = 16
|
||||
defaultDatabaseMaxIdleConns = 4
|
||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SaveDatabaseConfig updates or creates config.yaml with the specified database configuration.
|
||||
func SaveDatabaseConfig(dbType, dsn string) error {
|
||||
configPath := "config.yaml"
|
||||
data := make(map[string]any)
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read config.yaml: %w", err)
|
||||
}
|
||||
|
||||
dbSection, ok := data["database"].(map[string]any)
|
||||
if !ok {
|
||||
dbSection = make(map[string]any)
|
||||
}
|
||||
dbSection["type"] = dbType
|
||||
dbSection["dsn"] = dsn
|
||||
data["database"] = dbSection
|
||||
|
||||
out, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config.yaml: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config.yaml: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveDatabaseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable"
|
||||
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
|
||||
t.Fatalf("SaveDatabaseConfig error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
|
||||
t.Fatalf("expected config.yaml to exist: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if loaded.Database.Type != "postgres" {
|
||||
t.Fatalf("expected database.type=postgres, got %s", loaded.Database.Type)
|
||||
}
|
||||
if loaded.Database.DSN != dsn {
|
||||
t.Fatalf("expected dsn=%s, got %s", dsn, loaded.Database.DSN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// DatabaseStatus describes the currently active database engine and runtime metrics.
|
||||
type DatabaseStatus struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn,omitempty"`
|
||||
DBPath string `json:"db_path,omitempty"`
|
||||
OpenConns int `json:"open_conns"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
TableCounts map[string]int64 `json:"table_counts"`
|
||||
}
|
||||
|
||||
// PostgresTestResult returns latency and version info after testing connection.
|
||||
type PostgresTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseMigrationResult returns row counts and execution duration of migration.
|
||||
type DatabaseMigrationResult struct {
|
||||
Success bool `json:"success"`
|
||||
TotalRows int64 `json:"total_rows"`
|
||||
TableRows map[string]int64 `json:"table_rows"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InspectDatabaseStatus queries the currently active database for metrics and table rows.
|
||||
func InspectDatabaseStatus(db *gorm.DB, cfg *config.Config) *DatabaseStatus {
|
||||
st := &DatabaseStatus{
|
||||
Type: "sqlite",
|
||||
TableCounts: make(map[string]int64),
|
||||
}
|
||||
if cfg != nil {
|
||||
st.DBPath = cfg.Database.DBPath
|
||||
if cfg.Database.Type == "postgres" || (cfg.Database.Type == "auto" && strings.TrimSpace(cfg.Database.DSN) != "") {
|
||||
st.Type = "postgres"
|
||||
st.DSN = MaskDSN(cfg.Database.DSN)
|
||||
}
|
||||
}
|
||||
if isPostgres(db) {
|
||||
st.Type = "postgres"
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
stats := sqlDB.Stats()
|
||||
st.OpenConns = stats.OpenConnections
|
||||
st.InUse = stats.InUse
|
||||
st.Idle = stats.Idle
|
||||
st.MaxOpenConns = stats.MaxOpenConnections
|
||||
}
|
||||
|
||||
// Count rows for major model tables
|
||||
for _, m := range model.AllModels() {
|
||||
if tbl, err := modelTableName(db, m); err == nil {
|
||||
if db.Migrator().HasTable(tbl) {
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(1) FROM " + quoteIdent(tbl)).Scan(&count).Error; err == nil {
|
||||
st.TableCounts[tbl] = count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// TestPostgres establishes a temporary connection to verify reachability and permissions.
|
||||
func TestPostgres(dsn string) (*PostgresTestResult, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: "PostgreSQL DSN 不能为空",
|
||||
}, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
sqlDB, err := testDB.DB()
|
||||
if err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("获取底层连接失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
return &PostgresTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Ping 超时或失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var version string
|
||||
if err := testDB.WithContext(ctx).Raw("SELECT version()").Scan(&version).Error; err != nil {
|
||||
version = "PostgreSQL (unknown version)"
|
||||
}
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
return &PostgresTestResult{
|
||||
Success: true,
|
||||
LatencyMS: latency,
|
||||
Version: version,
|
||||
Message: "连接成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateCurrentToPostgres performs schema initialization and full table data copy into target PostgreSQL.
|
||||
func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log *zap.Logger) (*DatabaseMigrationResult, error) {
|
||||
targetDSN = strings.TrimSpace(targetDSN)
|
||||
if targetDSN == "" {
|
||||
return nil, fmt.Errorf("target PostgreSQL DSN cannot be empty")
|
||||
}
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("current database is not available")
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
targetDB, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
|
||||
Logger: newGormLogger(log),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open target PostgreSQL: %w", err)
|
||||
}
|
||||
targetSQLDB, err := targetDB.DB()
|
||||
if err == nil {
|
||||
defer targetSQLDB.Close()
|
||||
}
|
||||
|
||||
// 1. 初始化目标库 Schema、类型与索引
|
||||
if err := AutoMigrate(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
// 2. 安全重置目标数据库的初始默认数据
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, targetDB, log); err != nil {
|
||||
return nil, fmt.Errorf("reset target bootstrap data: %w", err)
|
||||
}
|
||||
|
||||
// 3. 执行数据批量复制
|
||||
tableRows, totalRows, err := copyModelTables(src, targetDB, batchSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy tables: %w", err)
|
||||
}
|
||||
|
||||
// 4. 标记迁移完成
|
||||
if err := markSQLiteMigrationComplete(targetDB); err != nil {
|
||||
return nil, fmt.Errorf("mark migration complete: %w", err)
|
||||
}
|
||||
|
||||
duration := time.Since(started).Milliseconds()
|
||||
return &DatabaseMigrationResult{
|
||||
Success: true,
|
||||
TotalRows: totalRows,
|
||||
TableRows: tableRows,
|
||||
DurationMS: duration,
|
||||
Message: fmt.Sprintf("成功迁移 %d 条记录至 PostgreSQL", totalRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaskDSN masks the password in a connection string for safe API responses.
|
||||
func MaskDSN(rawDSN string) string {
|
||||
rawDSN = strings.TrimSpace(rawDSN)
|
||||
if rawDSN == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(rawDSN); err == nil && u.User != nil {
|
||||
if pass, hasPassword := u.User.Password(); hasPassword && pass != "" {
|
||||
rawUserPass := u.User.String()
|
||||
user := u.User.Username()
|
||||
maskedUserPass := user + ":******"
|
||||
return strings.Replace(rawDSN, rawUserPass+"@", maskedUserPass+"@", 1)
|
||||
}
|
||||
}
|
||||
// Fallback for keyword-style DSN (e.g. host=... password=...)
|
||||
if strings.Contains(rawDSN, "password=") {
|
||||
parts := strings.Fields(rawDSN)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(p, "password=") {
|
||||
parts[i] = "password=******"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return rawDSN
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestMaskDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable",
|
||||
want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable",
|
||||
want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable",
|
||||
},
|
||||
{
|
||||
in: "sqlite://data/mmtl.db",
|
||||
want: "sqlite://data/mmtl.db",
|
||||
},
|
||||
{
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := MaskDSN(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("MaskDSN(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectDatabaseStatus(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Create(&model.User{Username: "testuser", PasswordHash: "h", Role: "user"}).Error
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
st := InspectDatabaseStatus(db, cfg)
|
||||
if st == nil {
|
||||
t.Fatal("expected non-nil DatabaseStatus")
|
||||
}
|
||||
if st.Type != "sqlite" {
|
||||
t.Fatalf("expected sqlite, got %s", st.Type)
|
||||
}
|
||||
if st.DBPath != "./data/mmtl.db" {
|
||||
t.Fatalf("expected db_path, got %s", st.DBPath)
|
||||
}
|
||||
if st.TableCounts["users"] != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", st.TableCounts["users"])
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
copied, err := copyModelTables(src, dst, 2)
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
copied, err := copyModelTables(src, dst, 2)
|
||||
_, copied, err := copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
|
||||
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
|
||||
}
|
||||
|
||||
copied, err = copyModelTables(src, dst, 2)
|
||||
_, copied, err = copyModelTables(src, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied, err := copyModelTables(src2, dst, 2)
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
||||
_ = sqlDB2.Close()
|
||||
}
|
||||
}()
|
||||
copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
_, copied, err := copyModelTables(src2, dst, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied rows = %d, want 2", copied)
|
||||
}
|
||||
var userCount int64
|
||||
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -48,7 +48,7 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za
|
||||
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
|
||||
return err
|
||||
}
|
||||
copied, err := copyModelTables(src, target, 500)
|
||||
_, copied, err := copyModelTables(src, target, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,52 +13,53 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
|
||||
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
var copied int64
|
||||
tableCounts := make(map[string]int64)
|
||||
var totalCopied int64
|
||||
for _, m := range model.AllModels() {
|
||||
table, err := modelTableName(src, m)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
primaryColumns, err := modelPrimaryColumns(src, m)
|
||||
if err != nil {
|
||||
return copied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
|
||||
}
|
||||
exists, err := sqliteTableExists(src, table)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var sourceCount int64
|
||||
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&sourceCount).Error; err != nil {
|
||||
return copied, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("count sqlite table %s: %w", table, err)
|
||||
}
|
||||
if sourceCount == 0 {
|
||||
continue
|
||||
}
|
||||
var targetCount int64
|
||||
if err := target.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&targetCount).Error; err != nil {
|
||||
return copied, fmt.Errorf("count target table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
|
||||
}
|
||||
modelType := reflect.TypeOf(m)
|
||||
if modelType.Kind() != reflect.Ptr {
|
||||
return copied, fmt.Errorf("model %T is not a pointer", m)
|
||||
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
|
||||
}
|
||||
sliceType := reflect.SliceOf(modelType.Elem())
|
||||
slicePtr := reflect.New(sliceType)
|
||||
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
|
||||
return copied, fmt.Errorf("read sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
|
||||
}
|
||||
filtered := slicePtr.Elem()
|
||||
if targetCount > 0 {
|
||||
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
|
||||
if err != nil {
|
||||
return copied, err
|
||||
return tableCounts, totalCopied, err
|
||||
}
|
||||
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
|
||||
}
|
||||
@@ -68,11 +69,13 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
|
||||
filteredPtr := reflect.New(filtered.Type())
|
||||
filteredPtr.Elem().Set(filtered)
|
||||
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
|
||||
return copied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
}
|
||||
copied += int64(filtered.Len())
|
||||
copiedForTable := int64(filtered.Len())
|
||||
tableCounts[table] = copiedForTable
|
||||
totalCopied += copiedForTable
|
||||
}
|
||||
return copied, nil
|
||||
return tableCounts, totalCopied, nil
|
||||
}
|
||||
|
||||
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -32,16 +33,37 @@ func installSQLiteWriteGate(db *gorm.DB) {
|
||||
gate.Unlock()
|
||||
}
|
||||
}
|
||||
rawLock := func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && isReadOnlySQL(tx.Statement.SQL.String()) {
|
||||
return
|
||||
}
|
||||
lock(tx)
|
||||
}
|
||||
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", lock)
|
||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock)
|
||||
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
|
||||
}
|
||||
|
||||
func isReadOnlySQL(sql string) bool {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "EXPLAIN") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(upper, "WITH") && !strings.Contains(upper, "INSERT") && !strings.Contains(upper, "UPDATE") && !strings.Contains(upper, "DELETE") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sqliteWriteGate serializes in-process SQLite writes while respecting the
|
||||
// statement context, so request cancellation can break out of a queued write.
|
||||
type sqliteWriteGate struct {
|
||||
@@ -84,7 +106,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
|
||||
}
|
||||
dsn := dbPath + "?_pragma=foreign_keys(1)"
|
||||
if cfg.Database.WALMode {
|
||||
dsn += "&_pragma=journal_mode(WAL)"
|
||||
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
|
||||
}
|
||||
if cfg.Database.BusyTimeout > 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
|
||||
@@ -92,6 +114,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
|
||||
if cfg.Database.CacheSize != 0 {
|
||||
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
|
||||
}
|
||||
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
|
||||
return dsn
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
type DatabaseConnectionPayload struct {
|
||||
Type string `json:"type"`
|
||||
DSN string `json:"dsn"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
SSLMode string `json:"sslmode"`
|
||||
}
|
||||
|
||||
func (p *DatabaseConnectionPayload) BuildDSN() string {
|
||||
raw := strings.TrimSpace(p.DSN)
|
||||
if raw != "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.TrimSpace(p.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := p.Port
|
||||
if port <= 0 {
|
||||
port = 5432
|
||||
}
|
||||
user := strings.TrimSpace(p.User)
|
||||
dbname := strings.TrimSpace(p.DBName)
|
||||
if dbname == "" {
|
||||
dbname = "mmtl"
|
||||
}
|
||||
sslmode := strings.TrimSpace(p.SSLMode)
|
||||
if sslmode == "" {
|
||||
sslmode = "disable"
|
||||
}
|
||||
|
||||
userInfo := url.User(user)
|
||||
if p.Password != "" {
|
||||
userInfo = url.UserPassword(user, p.Password)
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: userInfo,
|
||||
Host: fmt.Sprintf("%s:%d", host, port),
|
||||
Path: "/" + dbname,
|
||||
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func getDatabaseStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Database == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database service unavailable"})
|
||||
return
|
||||
}
|
||||
status := svc.Database.GetStatus(c.Request.Context())
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
|
||||
func testDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.TestPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDatabaseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dsn := req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供目标 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
res, err := svc.Database.MigrateToPostgres(c.Request.Context(), dsn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "迁移失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func saveDatabaseConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req DatabaseConnectionPayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
dbType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if dbType == "" {
|
||||
dbType = "postgres"
|
||||
}
|
||||
var dsn string
|
||||
if dbType == "postgres" {
|
||||
dsn = req.BuildDSN()
|
||||
if dsn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Database.SaveConfig(c.Request.Context(), dbType, dsn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "数据库配置已成功保存至配置文件,重启服务后将以新数据库运行",
|
||||
"type": dbType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestBuildDSN(t *testing.T) {
|
||||
cases := []struct {
|
||||
payload DatabaseConnectionPayload
|
||||
want string
|
||||
}{
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
DSN: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
want: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
|
||||
},
|
||||
{
|
||||
payload: DatabaseConnectionPayload{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "secretpassword",
|
||||
DBName: "mmtl_prod",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := c.payload.BuildDSN()
|
||||
if got != c.want {
|
||||
t.Errorf("BuildDSN() = %q, want %q", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatabaseStatusHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = "./data/mmtl.db"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/admin/database/status", getDatabaseStatusHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/database/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["type"] != "sqlite" {
|
||||
t.Fatalf("expected type=sqlite, got %v", resp["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDatabaseConfigHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Database.Type = "sqlite"
|
||||
|
||||
svc := &service.Container{
|
||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
|
||||
|
||||
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
+87
-12
@@ -16,12 +16,13 @@ import (
|
||||
)
|
||||
|
||||
type createLibraryReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CreatePerSubfolder bool `json:"create_per_subfolder"`
|
||||
}
|
||||
|
||||
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -44,6 +45,22 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
if withPreview {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, previews)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, libs)
|
||||
}
|
||||
}
|
||||
@@ -88,9 +105,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
}
|
||||
var l *model.Library
|
||||
if req.CreatePerSubfolder {
|
||||
parent := ""
|
||||
if len(roots) > 0 {
|
||||
parent = roots[0].Path
|
||||
} else if strings.TrimSpace(req.Path) != "" {
|
||||
parent = req.Path
|
||||
}
|
||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get("ctx_user_id")
|
||||
for i := range created {
|
||||
lib := &created[i]
|
||||
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
|
||||
if svc.Watcher != nil {
|
||||
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||
}
|
||||
for _, root := range lib.Roots {
|
||||
if root.Enabled {
|
||||
queueLibraryRootScan(svc, lib.ID, root.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"libraries": created})
|
||||
return
|
||||
}
|
||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -117,7 +163,9 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
type updateLibraryReq struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverURL *string `json:"cover_url"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
CarouselEnabled *bool `json:"carousel_enabled"`
|
||||
}
|
||||
|
||||
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -127,9 +175,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
if req.CoverURL != nil {
|
||||
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SortOrder != nil || req.CarouselEnabled != nil {
|
||||
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || lib == nil {
|
||||
@@ -140,6 +196,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
type reorderLibrariesReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req reorderLibrariesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -20,6 +20,29 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
registerAdminAPIConfigRoutes(admin, svc)
|
||||
registerAdminRecognitionWordRoutes(admin, svc)
|
||||
registerAdminStrmRoutes(admin, svc)
|
||||
registerAdminScraperRoutes(admin, svc)
|
||||
registerAdminDatabaseRoutes(admin, svc)
|
||||
|
||||
// FFmpeg/FFprobe 工具:状态查询 + 一键下载安装(自动匹配当前平台)。
|
||||
admin.GET("/tools/ffmpeg/status", ffToolsStatusHandler(svc))
|
||||
admin.POST("/tools/ffmpeg/install", ffToolsInstallHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/scraper/queue", listScrapeQueueHandler(svc))
|
||||
admin.POST("/scraper/queue/:id/cancel", cancelScrapeTaskHandler(svc))
|
||||
admin.POST("/scraper/queue/:id/retry", retryScrapeTaskHandler(svc))
|
||||
admin.DELETE("/scraper/queue/:id", deleteScrapeTaskHandler(svc))
|
||||
admin.POST("/scraper/queue/batch", batchActionScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
|
||||
admin.POST("/scraper/queue/enqueue-all", enqueueAllScrapeHandler(svc))
|
||||
admin.POST("/media/repair-rescrape", enqueueAllScrapeHandler(svc))
|
||||
admin.POST("/libraries/:id/repair-rescrape", enqueueLibraryScrapeHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
@@ -43,11 +66,15 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/strm/paths/:id/sync", startStrmSyncHandler(svc))
|
||||
admin.POST("/strm/paths/:id/cancel", cancelStrmSyncHandler(svc))
|
||||
admin.GET("/strm/records", listStrmSyncRecordsHandler(svc))
|
||||
admin.DELETE("/strm/records/:id", deleteStrmSyncRecordHandler(svc))
|
||||
admin.DELETE("/strm/records", clearStrmSyncRecordsHandler(svc))
|
||||
admin.GET("/strm/local-dirs", listStrmLocalDirsHandler(svc))
|
||||
|
||||
admin.GET("/strm/downloads", downloadQueueHandler(svc))
|
||||
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
|
||||
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
|
||||
admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc))
|
||||
admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
|
||||
@@ -56,6 +83,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/strm/uploads", uploadQueueHandler(svc))
|
||||
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
|
||||
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
|
||||
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
|
||||
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
|
||||
}
|
||||
@@ -114,3 +143,10 @@ func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Con
|
||||
admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc))
|
||||
admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminDatabaseRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/database/status", getDatabaseStatusHandler(svc))
|
||||
admin.POST("/database/test", testDatabaseHandler(svc))
|
||||
admin.POST("/database/migrate", migrateDatabaseHandler(svc))
|
||||
admin.POST("/database/save-config", saveDatabaseConfigHandler(svc))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
|
||||
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
||||
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
|
||||
@@ -38,6 +39,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func listScrapeQueueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
snap, err := svc.Scraper.ScrapeQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.CancelScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func retryScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.RetryScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.DeleteScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func batchActionScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Scraper.BatchActionScrapeTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
func clearDoneScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearDoneScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func clearFinishedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearFinishedScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearCanceledScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"retried": n})
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPendingScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.CancelPendingScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"canceled": n})
|
||||
}
|
||||
}
|
||||
|
||||
type enqueueScrapeReq struct {
|
||||
EpisodeImages bool `json:"episode_images"`
|
||||
EpisodeArtwork bool `json:"episode_artwork"`
|
||||
RefreshMatched bool `json:"refresh_matched"`
|
||||
IncludeMatched bool `json:"include_matched"`
|
||||
}
|
||||
|
||||
func (r enqueueScrapeReq) toOptions() service.ScrapeOptions {
|
||||
epArtwork := r.EpisodeImages || r.EpisodeArtwork
|
||||
return service.ScrapeOptions{
|
||||
EpisodeArtwork: &epArtwork,
|
||||
IncludeMatched: r.IncludeMatched || r.RefreshMatched,
|
||||
RetryNoMatch: true,
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueLibraryScrapeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req enqueueScrapeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
libID := c.Param("id")
|
||||
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, req.toOptions())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enqueued": n})
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueAllScrapeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req enqueueScrapeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
n, err := svc.Scraper.EnqueueAll(c.Request.Context(), req.toOptions())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enqueued": n})
|
||||
}
|
||||
}
|
||||
@@ -128,3 +128,19 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -135,28 +134,12 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
task := startScrapeHTTPTask(svc, "手动刮削媒体", m.Title, m.Path)
|
||||
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
|
||||
finishHTTPTask(task, err, "scrape", "手动刮削媒体失败", nil, nil)
|
||||
task, err := svc.Scraper.EnqueueMedia(c.Request.Context(), c.Param("id"), options)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
|
||||
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
|
||||
metrics := map[string]int64{"processed": 1}
|
||||
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
|
||||
metrics["matched"] = 1
|
||||
}
|
||||
if reclassified > 0 {
|
||||
metrics["reclassified"] = int64(reclassified)
|
||||
}
|
||||
finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil)
|
||||
c.JSON(http.StatusOK, refreshed)
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,40 +153,12 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
var task *service.TaskHandle
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
|
||||
} else {
|
||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", libID, "")
|
||||
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, options)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Run in the background so HTTP returns instantly; the WS hub
|
||||
// pushes per-item progress on the "scrape" topic.
|
||||
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
|
||||
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
|
||||
reclassified := 0
|
||||
if result.Processed > 0 {
|
||||
reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID)
|
||||
}
|
||||
metrics := map[string]int64{
|
||||
"matched": int64(result.Matched),
|
||||
"processed": int64(result.Processed),
|
||||
"candidates": int64(result.Candidates),
|
||||
}
|
||||
if reclassified > 0 {
|
||||
metrics["reclassified"] = int64(reclassified)
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
metrics["errors"] = int64(result.Failed)
|
||||
}
|
||||
stage := "completed"
|
||||
message := "手动刮削媒体库结束"
|
||||
if err != nil {
|
||||
stage = "scrape"
|
||||
message = "手动刮削媒体库失败"
|
||||
}
|
||||
finishHTTPTask(task, err, stage, message, metrics, nil)
|
||||
}(libID, task, options)
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "queued", "enqueued": n})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,31 @@ func listStrmSyncRecordsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStrmSyncRecordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少记录 ID"})
|
||||
return
|
||||
}
|
||||
if err := svc.Strm.DeleteSyncRecord(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func clearStrmSyncRecordsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deleted, err := svc.Strm.ClearSyncRecords(c.Request.Context(), c.Query("path_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": deleted})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 下载/上传队列 ─────────────────────────────────────────────────────────────
|
||||
|
||||
func downloadQueueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -368,6 +393,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
type queueBatchReq struct {
|
||||
Action string `json:"action" binding:"required"`
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
|
||||
|
||||
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
@@ -31,7 +31,7 @@ func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/vtt; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Package handler — system tools detection.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// SystemHandler handles system-related endpoints.
|
||||
type SystemHandler struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
svc *service.Container
|
||||
}
|
||||
|
||||
// NewSystemHandler is the constructor.
|
||||
func NewSystemHandler(cfg *config.Config, log *zap.Logger, svc *service.Container) *SystemHandler {
|
||||
return &SystemHandler{cfg: cfg, log: log, svc: svc}
|
||||
}
|
||||
|
||||
// ToolStatus represents the detection status of a system tool.
|
||||
type ToolStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Detected bool `json:"detected"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// GetToolsStatus returns the status of system tools.
|
||||
func (h *SystemHandler) GetToolsStatus(c *gin.Context) {
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
// Check configured path first
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the tool
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
// Try to get version
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tools": tools,
|
||||
})
|
||||
}
|
||||
|
||||
// getToolVersion attempts to get the version of a tool.
|
||||
func getToolVersion(path string) string {
|
||||
out, err := exec.Command(path, "-version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract first line as version info
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) > 0 {
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallTools attempts to auto-install system tools (ffmpeg/ffprobe)
|
||||
func (h *SystemHandler) InstallTools(c *gin.Context) {
|
||||
h.log.Info("Received tools auto-install request")
|
||||
|
||||
// Call service layer to auto-install
|
||||
ffprobePath, ffmpegPath := service.AutoInstallFFmpeg(h.log, h.cfg)
|
||||
|
||||
result := gin.H{
|
||||
"installed": ffprobePath != "" || ffmpegPath != "",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
result["ffprobe_path"] = ffprobePath
|
||||
result["ffprobe_installed"] = true
|
||||
}
|
||||
if ffmpegPath != "" {
|
||||
result["ffmpeg_path"] = ffmpegPath
|
||||
result["ffmpeg_installed"] = true
|
||||
}
|
||||
|
||||
// Re-detect tool status
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
result["tools"] = tools
|
||||
|
||||
h.log.Info("Tool installation completed", zap.Any("result", result))
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package handler — FFmpeg/FFprobe 工具安装端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// ffToolsStatusHandler 返回 ffmpeg/ffprobe 当前安装状态
|
||||
// (GET /api/admin/tools/ffmpeg/status)。
|
||||
func ffToolsStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
// ffToolsInstallHandler 触发后台下载安装(POST /api/admin/tools/ffmpeg/install)。
|
||||
// 自动匹配当前运行环境(OS+架构),安装到 data/tools/ffmpeg/ 并把路径写入设置。
|
||||
func ffToolsInstallHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
if err := svc.FFTools.StartInstall(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package model
|
||||
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
||||
type Library struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Path string `gorm:"size:1024;not null" json:"path"`
|
||||
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Path string `gorm:"size:1024;not null" json:"path"`
|
||||
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
|
||||
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
|
||||
|
||||
@@ -57,5 +57,6 @@ func AllModels() []interface{} {
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ScrapeTaskPending = "pending"
|
||||
ScrapeTaskRunning = "running"
|
||||
ScrapeTaskDone = "done"
|
||||
ScrapeTaskFailed = "failed"
|
||||
ScrapeTaskCanceled = "canceled"
|
||||
)
|
||||
|
||||
// ScrapeTask 表示一条持久化的媒体刮削任务。
|
||||
type ScrapeTask struct {
|
||||
Base
|
||||
MediaID string `gorm:"index;size:36" json:"media_id"`
|
||||
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
||||
LibraryName string `gorm:"size:128" json:"library_name"`
|
||||
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
|
||||
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
|
||||
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
|
||||
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
|
||||
MatchedTitle string `gorm:"size:255" json:"matched_title"`
|
||||
MatchedYear int `json:"matched_year"`
|
||||
PosterURL string `gorm:"size:1024" json:"poster_url"`
|
||||
BackdropURL string `gorm:"size:1024" json:"backdrop_url"`
|
||||
Status string `gorm:"index;size:16;default:pending" json:"status"` // pending / running / done / failed / canceled
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
RetryCount int `gorm:"default:0" json:"retry_count"`
|
||||
EpisodeImages bool `gorm:"default:true" json:"episode_images"`
|
||||
RefreshMatched bool `gorm:"default:false" json:"refresh_matched"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
@@ -15,6 +15,11 @@ type LibraryRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create persists a new library row.
|
||||
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
|
||||
if l != nil && l.SortOrder == 0 {
|
||||
var maxSort int
|
||||
_ = r.db.WithContext(ctx).Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
l.SortOrder = maxSort + 1
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(l).Error
|
||||
}
|
||||
|
||||
@@ -23,6 +28,11 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
|
||||
return r.Create(ctx, l)
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if l != nil && l.SortOrder == 0 {
|
||||
var maxSort int
|
||||
_ = tx.Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
l.SortOrder = maxSort + 1
|
||||
}
|
||||
if err := tx.Create(l).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +60,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
|
||||
// List returns all enabled+disabled libraries.
|
||||
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
|
||||
var ls []model.Library
|
||||
q := r.db.WithContext(ctx).Order("created_at asc")
|
||||
q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc")
|
||||
if r.hasLibraryRootsTable() {
|
||||
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("sort_order asc, created_at asc")
|
||||
@@ -60,6 +70,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
|
||||
return ls, err
|
||||
}
|
||||
|
||||
// SetSortOrder assigns sort_order to libraries, preserving position order for
|
||||
// any library not present in the provided map.
|
||||
func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Library{}).Where("id = ?", id).
|
||||
Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// FindByID returns the library, or (nil, nil) when missing.
|
||||
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
|
||||
var l model.Library
|
||||
|
||||
@@ -114,6 +114,35 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
type libraryCountRow struct {
|
||||
LibraryID string `gorm:"column:library_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
|
||||
// CountByLibraries returns a map of library_id -> total media count for the given library IDs.
|
||||
func (r *MediaRepository) CountByLibraries(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) (map[string]int64, error) {
|
||||
out := make(map[string]int64, len(libraryIDs))
|
||||
if len(libraryIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []libraryCountRow
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("library_id, count(*) as total")
|
||||
if len(libraryIDs) == 1 {
|
||||
q = q.Where("library_id = ?", libraryIDs[0])
|
||||
} else {
|
||||
q = q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Group("library_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.LibraryID] = row.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteByLibrary purges all media tied to a library.
|
||||
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
|
||||
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
|
||||
|
||||
@@ -30,9 +30,10 @@ type Container struct {
|
||||
StrmSyncPath *StrmSyncPathRepository
|
||||
StrmSyncRecord *StrmSyncRecordRepository
|
||||
StrmDownload *StrmDownloadTaskRepository
|
||||
StrmUpload *StrmUploadTaskRepository
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
}
|
||||
StrmUpload *StrmUploadTaskRepository
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
ScrapeTask *ScrapeTaskRepository
|
||||
}
|
||||
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
func New(db *gorm.DB) *Container {
|
||||
@@ -60,5 +61,6 @@ func New(db *gorm.DB) *Container {
|
||||
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
||||
StrmUpload: &StrmUploadTaskRepository{db: db},
|
||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
var scrapeClaimMu sync.Mutex
|
||||
|
||||
// ScrapeTaskRepository persists model.ScrapeTask.
|
||||
type ScrapeTaskRepository struct{ db *gorm.DB }
|
||||
|
||||
func (r *ScrapeTaskRepository) Create(ctx context.Context, t *model.ScrapeTask) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Create(t).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CreateBatch(ctx context.Context, tasks []model.ScrapeTask) error {
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).CreateInBatches(tasks, 100).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) FindByID(ctx context.Context, id string) (*model.ScrapeTask, error) {
|
||||
var t model.ScrapeTask
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID string) (*model.ScrapeTask, error) {
|
||||
var t model.ScrapeTask
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("media_id = ? AND status IN ?", mediaID, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 50
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.ScrapeTask{})
|
||||
if strings.TrimSpace(status) != "" && status != "all" {
|
||||
q = q.Where("status = ?", strings.TrimSpace(status))
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.ScrapeTask
|
||||
err := q.Order("created_at desc").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
|
||||
var rows []struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
err := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Select("status, count(*) as count").
|
||||
Group("status").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
out[row.Status] = row.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClaimPending picks pending scrape tasks and marks them running.
|
||||
func (r *ScrapeTaskRepository) ClaimPending(ctx context.Context, limit int) ([]model.ScrapeTask, error) {
|
||||
scrapeClaimMu.Lock()
|
||||
defer scrapeClaimMu.Unlock()
|
||||
|
||||
var rows []model.ScrapeTask
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("status = ?", model.ScrapeTaskPending).
|
||||
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
now := time.Now()
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].ID)
|
||||
rows[i].Status = model.ScrapeTaskRunning
|
||||
rows[i].StartedAt = &now
|
||||
}
|
||||
return tx.Model(&model.ScrapeTask{}).Where("id IN ?", ids).
|
||||
Updates(map[string]any{"status": model.ScrapeTaskRunning, "started_at": now}).Error
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) Update(ctx context.Context, t *model.ScrapeTask) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.ScrapeTask{}).Where("id = ?", t.ID).Updates(map[string]any{
|
||||
"status": t.Status,
|
||||
"error": t.Error,
|
||||
"provider": t.Provider,
|
||||
"matched_title": t.MatchedTitle,
|
||||
"matched_year": t.MatchedYear,
|
||||
"poster_url": t.PosterURL,
|
||||
"backdrop_url": t.BackdropURL,
|
||||
"retry_count": t.RetryCount,
|
||||
"started_at": t.StartedAt,
|
||||
"finished_at": t.FinishedAt,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.ScrapeTask{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearDone(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskDone).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.ScrapeTaskDone, model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
|
||||
Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskCanceled).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("status = ?", model.ScrapeTaskFailed).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CancelPending(ctx context.Context) (int64, error) {
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("status IN ?", []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
@@ -170,6 +170,24 @@ func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string,
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Delete 删除单条同步记录(物理删除)。
|
||||
func (r *StrmSyncRecordRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmSyncRecord{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBySyncPathID 删除某同步目录下的全部同步记录(删除同步目录时级联清理)。
|
||||
func (r *StrmSyncRecordRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmSyncRecord{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ─── StrmDownloadTask ──────────────────────────────────────────────────────────
|
||||
|
||||
// StrmDownloadTaskRepository persists model.StrmDownloadTask.
|
||||
@@ -290,6 +308,66 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除指定 ID 的下载任务。
|
||||
func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。
|
||||
func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"next_try_at": nil,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。
|
||||
func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearDone 清空全部已完成下载任务。
|
||||
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
@@ -537,6 +615,66 @@ func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除指定 ID 的上传任务。
|
||||
func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。
|
||||
func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"next_try_at": nil,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。
|
||||
func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearCanceled 清空全部已取消上传任务。
|
||||
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
|
||||
@@ -17,6 +17,9 @@ package cloud
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud115"
|
||||
@@ -61,16 +64,16 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range files {
|
||||
out = append(out, FileEntry{
|
||||
ID: f.FileId,
|
||||
Name: f.FileName,
|
||||
IsDir: f.Category == cloud115.TypeDir,
|
||||
Size: f.FileSize,
|
||||
MTime: f.Utime,
|
||||
PickCode: f.PickCode,
|
||||
})
|
||||
}
|
||||
for _, f := range files {
|
||||
out = append(out, FileEntry{
|
||||
ID: f.FileId,
|
||||
Name: f.FileName,
|
||||
IsDir: f.Category == cloud115.TypeDir,
|
||||
Size: f.FileSize,
|
||||
MTime: f.Utime,
|
||||
PickCode: f.PickCode,
|
||||
})
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
break
|
||||
}
|
||||
@@ -105,6 +108,39 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
|
||||
|
||||
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
|
||||
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
|
||||
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
|
||||
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
|
||||
tmp, err := os.CreateTemp("", "mmtl-upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时文件失败:%w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return fmt.Errorf("115: 写入临时文件失败:%w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
|
||||
}
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名
|
||||
if fileName != "" && fileName != filepath.Base(tmpPath) {
|
||||
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
|
||||
if err := os.Rename(tmpPath, namedPath); err == nil {
|
||||
tmpPath = namedPath
|
||||
}
|
||||
}
|
||||
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌并返回新令牌;refresh_token 失效时返回
|
||||
// cloud115.IsRefreshTokenDead(err) 为 true 的错误。
|
||||
func (p *openAPI115Provider) RefreshToken(refreshToken string) (*cloud115.TokenData, error) {
|
||||
|
||||
@@ -185,7 +185,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
if access && c.tryRefreshTokenLocked() {
|
||||
if access && c.tryRefreshTokenLocked(ctx) {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
@@ -259,19 +259,43 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
}
|
||||
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
|
||||
func (c *OpenClient) tryRefreshTokenLocked() bool {
|
||||
// 应使用内存中的新 token 重试原请求)。
|
||||
//
|
||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err != nil {
|
||||
for attempt := 0; attempt < refreshAttempts; attempt++ {
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err == nil {
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
}
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
return false
|
||||
}
|
||||
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
|
||||
if attempt < refreshAttempts-1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(refreshBackoff(attempt)):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
|
||||
const refreshAttempts = 3
|
||||
|
||||
// refreshBackoff 返回第 attempt 次(从 0 计)刷新失败后的退避时长(指数退避)。
|
||||
func refreshBackoff(attempt int) time.Duration {
|
||||
return time.Duration(200*(1<<attempt)) * time.Millisecond // 200ms, 400ms
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
@@ -281,7 +305,7 @@ func IsThrottleCode(code int) bool {
|
||||
|
||||
func isTokenCode(code int) bool {
|
||||
switch code {
|
||||
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, RefreshTokenInvalid:
|
||||
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, AccessTokenFormatInvalid, RefreshTokenInvalid:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -371,62 +371,104 @@ func TestThrottleCodeHandling(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected throttle error")
|
||||
}
|
||||
if !tm.IsThrottled() {
|
||||
t.Fatal("code 770004 should trigger throttle status")
|
||||
}
|
||||
if !tm.IsThrottled() {
|
||||
t.Fatal("code 770004 should trigger throttle status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFileDetailRelativePath(t *testing.T) {
|
||||
rootCID := "3238787832374488117" // 影视库
|
||||
|
||||
// 场景 1:115 目录 paths 中只有祖先目录链,不包含自身
|
||||
d1 := &RemoteFileDetail{
|
||||
FileId: "3251154147730910635",
|
||||
FileName: "出包王女",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: "3238787832374488117", Name: "影视库"},
|
||||
{FileId: "3238787913223892116", Name: "动漫"},
|
||||
},
|
||||
}
|
||||
if got := d1.RelativePath(rootCID); got != "动漫/出包王女" {
|
||||
t.Errorf("d1.RelativePath = %q, want %q", got, "动漫/出包王女")
|
||||
}
|
||||
|
||||
func TestRemoteFileDetailRelativePath(t *testing.T) {
|
||||
rootCID := "3238787832374488117" // 影视库
|
||||
|
||||
// 场景 1:115 目录 paths 中只有祖先目录链,不包含自身
|
||||
d1 := &RemoteFileDetail{
|
||||
FileId: "3251154147730910635",
|
||||
FileName: "出包王女",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: "3238787832374488117", Name: "影视库"},
|
||||
{FileId: "3238787913223892116", Name: "动漫"},
|
||||
},
|
||||
}
|
||||
if got := d1.RelativePath(rootCID); got != "动漫/出包王女" {
|
||||
t.Errorf("d1.RelativePath = %q, want %q", got, "动漫/出包王女")
|
||||
}
|
||||
|
||||
// 场景 2:祖先中间目录,自身在 paths 末尾
|
||||
d2 := &RemoteFileDetail{
|
||||
FileId: "3238787913223892116",
|
||||
FileName: "动漫",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: "3238787832374488117", Name: "影视库"},
|
||||
{FileId: "3238787913223892116", Name: "动漫"},
|
||||
},
|
||||
}
|
||||
if got := d2.RelativePath(rootCID); got != "动漫" {
|
||||
t.Errorf("d2.RelativePath = %q, want %q", got, "动漫")
|
||||
}
|
||||
|
||||
// 场景 3:根同步目录自身
|
||||
d3 := &RemoteFileDetail{
|
||||
FileId: rootCID,
|
||||
FileName: "影视库",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: rootCID, Name: "影视库"},
|
||||
},
|
||||
}
|
||||
if got := d3.RelativePath(rootCID); got != "" {
|
||||
t.Errorf("d3.RelativePath = %q, want %q", got, "")
|
||||
}
|
||||
// 场景 2:祖先中间目录,自身在 paths 末尾
|
||||
d2 := &RemoteFileDetail{
|
||||
FileId: "3238787913223892116",
|
||||
FileName: "动漫",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: "3238787832374488117", Name: "影视库"},
|
||||
{FileId: "3238787913223892116", Name: "动漫"},
|
||||
},
|
||||
}
|
||||
if got := d2.RelativePath(rootCID); got != "动漫" {
|
||||
t.Errorf("d2.RelativePath = %q, want %q", got, "动漫")
|
||||
}
|
||||
|
||||
// 场景 3:根同步目录自身
|
||||
d3 := &RemoteFileDetail{
|
||||
FileId: rootCID,
|
||||
FileName: "影视库",
|
||||
Paths: []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
}{
|
||||
{FileId: "0", Name: "根目录"},
|
||||
{FileId: rootCID, Name: "影视库"},
|
||||
},
|
||||
}
|
||||
if got := d3.RelativePath(rootCID); got != "" {
|
||||
t.Errorf("d3.RelativePath = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFsListRefreshContinue 验证 access_token 在请求中途过期(40140126)时:
|
||||
// 自动用 refresh_token 刷新得到新 token,然后对原请求重试成功(同步得以继续)。
|
||||
func TestFsListRefreshContinue(t *testing.T) {
|
||||
var filesCalls int
|
||||
var refreshCalls int
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/refreshToken":
|
||||
refreshCalls++
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
|
||||
case "/open/ufile/files":
|
||||
filesCalls++
|
||||
switch filesCalls {
|
||||
case 1:
|
||||
// 第一次用旧 access_token,返回过期错误,应触发刷新
|
||||
w.Write([]byte(`{"state":false,"code":40140126,"message":"access_token 校验失败"}`))
|
||||
default:
|
||||
// 刷新后续请求应使用新 access_token
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer at2" {
|
||||
t.Errorf("retried request auth = %q, want Bearer at2", got)
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"path":[],"data":[{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
files, _, err := c.GetFsList(context.Background(), "0", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("expected sync to continue after refresh, got error: %v", err)
|
||||
}
|
||||
if filesCalls != 2 {
|
||||
t.Fatalf("want 2 files calls (original + retried), got %d", filesCalls)
|
||||
}
|
||||
if refreshCalls == 0 {
|
||||
t.Fatal("expected refresh_token to be used once")
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("want 1 file, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ var (
|
||||
|
||||
const (
|
||||
// 业务错误码
|
||||
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
|
||||
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
|
||||
AccessAuthInvalid = 40140124 // 访问无效,需刷新
|
||||
RefreshTokenInvalid = 40140116 // 需重新授权
|
||||
TokenRefreshFail = 40140121 // 刷新失败,可重试
|
||||
RequestMaxLimitCode = 770004 // 访问频率过高
|
||||
RequestRateLimitCode = 406 // 达到访问上限
|
||||
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
|
||||
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
|
||||
AccessAuthInvalid = 40140124 // 访问无效,需刷新
|
||||
AccessTokenFormatInvalid = 40140123 // access_token 格式错误,需刷新
|
||||
RefreshTokenInvalid = 40140116 // 需重新授权
|
||||
TokenRefreshFail = 40140121 // 刷新失败,可重试
|
||||
RequestMaxLimitCode = 770004 // 访问频率过高
|
||||
RequestRateLimitCode = 406 // 达到访问上限
|
||||
|
||||
// 刷新 token 的错误码
|
||||
RefreshTokenFormatInvalid = 40140114
|
||||
|
||||
@@ -185,7 +185,6 @@ func (d *RemoteFileDetail) RelativePath(rootCID string) string {
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
|
||||
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type downloadURLData struct {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// 阿里云 OSS multipart 分片上传(用于 115 元数据上传直传)。
|
||||
// 使用 115 下发的临时 STS 凭证,将本地文件分片上传到 OSS,并经 complete 回调
|
||||
// 通知 115 完成落盘。参考 QMediaSync 的 OSSMultipartUploader 实现。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMultipartPartSize int64 = 32 * 1024 * 1024
|
||||
multipartPartAlign int64 = 1024 * 1024
|
||||
maxMultipartParts int64 = 9999
|
||||
maxMultipartPartSize int64 = 5 * 1024 * 1024 * 1024
|
||||
)
|
||||
|
||||
type ossMultipartClient interface {
|
||||
InitiateMultipartUpload(context.Context, *oss.InitiateMultipartUploadRequest, ...func(*oss.Options)) (*oss.InitiateMultipartUploadResult, error)
|
||||
UploadPart(context.Context, *oss.UploadPartRequest, ...func(*oss.Options)) (*oss.UploadPartResult, error)
|
||||
ListParts(context.Context, *oss.ListPartsRequest, ...func(*oss.Options)) (*oss.ListPartsResult, error)
|
||||
CompleteMultipartUpload(context.Context, *oss.CompleteMultipartUploadRequest, ...func(*oss.Options)) (*oss.CompleteMultipartUploadResult, error)
|
||||
AbortMultipartUpload(context.Context, *oss.AbortMultipartUploadRequest, ...func(*oss.Options)) (*oss.AbortMultipartUploadResult, error)
|
||||
}
|
||||
|
||||
// OSSMultipartUploader 封装 OSS multipart 上传。
|
||||
type OSSMultipartUploader struct {
|
||||
client ossMultipartClient
|
||||
}
|
||||
|
||||
// OSSMultipartUploadInput 是 multipart 上传输入。
|
||||
type OSSMultipartUploadInput struct {
|
||||
Bucket string
|
||||
Object string
|
||||
Callback string
|
||||
CallbackVar string
|
||||
FilePath string
|
||||
FileSize int64
|
||||
UploadId string
|
||||
PartSize int64
|
||||
PartRetryMax int
|
||||
refreshClient func(context.Context) (ossMultipartClient, error)
|
||||
}
|
||||
|
||||
// OSSMultipartUploadResult 是 multipart 上传后的结果。
|
||||
type OSSMultipartUploadResult struct {
|
||||
CallbackResult map[string]any
|
||||
UploadId string
|
||||
PartSize int64
|
||||
TotalParts int
|
||||
UploadedBytes int64
|
||||
UploadedParts int
|
||||
}
|
||||
|
||||
// CalculateMultipartPartSize 计算 OSS multipart 分片大小与分片数量。
|
||||
func CalculateMultipartPartSize(fileSize int64) (int64, int, error) {
|
||||
if fileSize < 0 {
|
||||
return 0, 0, fmt.Errorf("文件大小不能为负数:%d", fileSize)
|
||||
}
|
||||
partSize := defaultMultipartPartSize
|
||||
minPartSize := ceilDiv(fileSize, maxMultipartParts)
|
||||
if minPartSize > partSize {
|
||||
partSize = roundUp(minPartSize, multipartPartAlign)
|
||||
}
|
||||
if partSize > maxMultipartPartSize {
|
||||
return 0, 0, fmt.Errorf("文件过大,所需分片大小 %d 超过 OSS 上限 %d", partSize, maxMultipartPartSize)
|
||||
}
|
||||
totalParts := int(ceilDiv(fileSize, partSize))
|
||||
if totalParts == 0 {
|
||||
totalParts = 1
|
||||
}
|
||||
if int64(totalParts) > maxMultipartParts {
|
||||
return 0, 0, fmt.Errorf("分片数量 %d 超过上限 %d", totalParts, maxMultipartParts)
|
||||
}
|
||||
return partSize, totalParts, nil
|
||||
}
|
||||
|
||||
// NewOSSMultipartUploader 创建 OSS multipart 上传器。
|
||||
func NewOSSMultipartUploader(endpoint, accessKeyId, accessKeySecret, securityToken string) *OSSMultipartUploader {
|
||||
return &OSSMultipartUploader{client: newOSSMultipartClient(endpoint, accessKeyId, accessKeySecret, securityToken)}
|
||||
}
|
||||
|
||||
func newOSSMultipartClient(endpoint, accessKeyId, accessKeySecret, securityToken string) ossMultipartClient {
|
||||
cfg := oss.LoadDefaultConfig().
|
||||
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, securityToken)).
|
||||
WithRegion("cn-shenzhen").
|
||||
WithEndpoint(endpoint)
|
||||
return oss.NewClient(cfg)
|
||||
}
|
||||
|
||||
// UploadFile 上传文件并完成 OSS multipart,返回 complete callback 结果。
|
||||
func (u *OSSMultipartUploader) UploadFile(ctx context.Context, input OSSMultipartUploadInput) (map[string]any, error) {
|
||||
result, err := u.UploadFileWithResult(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.CallbackResult, nil
|
||||
}
|
||||
|
||||
// UploadFileWithResult 上传文件并返回 multipart 结果。
|
||||
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (OSSMultipartUploadResult, error) {
|
||||
if input.PartRetryMax <= 0 {
|
||||
input.PartRetryMax = 3
|
||||
}
|
||||
partSize := input.PartSize
|
||||
totalParts := 0
|
||||
var err error
|
||||
if partSize <= 0 {
|
||||
partSize, totalParts, err = CalculateMultipartPartSize(input.FileSize)
|
||||
if err != nil {
|
||||
return OSSMultipartUploadResult{}, err
|
||||
}
|
||||
} else {
|
||||
totalParts = int(ceilDiv(input.FileSize, partSize))
|
||||
}
|
||||
|
||||
uploadId := input.UploadId
|
||||
if uploadId == "" {
|
||||
initResult, err := u.client.InitiateMultipartUpload(ctx, &oss.InitiateMultipartUploadRequest{
|
||||
Bucket: oss.Ptr(input.Bucket),
|
||||
Key: oss.Ptr(input.Object),
|
||||
RequestCommon: oss.RequestCommon{
|
||||
Parameters: map[string]string{"sequential": "1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", err)
|
||||
}
|
||||
if initResult.UploadId == nil || *initResult.UploadId == "" {
|
||||
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 返回空 upload_id")
|
||||
}
|
||||
uploadId = *initResult.UploadId
|
||||
}
|
||||
|
||||
existingPartMap := make(map[int32]int64)
|
||||
existingParts, err := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId)
|
||||
if err == nil {
|
||||
for _, part := range existingParts {
|
||||
existingPartMap[part.PartNumber] = part.Size
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Open(input.FilePath)
|
||||
if err != nil {
|
||||
return OSSMultipartUploadResult{}, fmt.Errorf("打开待上传文件失败:%w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var uploadedBytes int64
|
||||
uploadedParts := 0
|
||||
completeParts := make([]oss.UploadPart, 0, totalParts)
|
||||
for partNumber := 1; partNumber <= totalParts; partNumber++ {
|
||||
offset := int64(partNumber-1) * partSize
|
||||
length := minInt64(partSize, input.FileSize-offset)
|
||||
if length < 0 {
|
||||
length = 0
|
||||
}
|
||||
if existingSize, ok := existingPartMap[int32(partNumber)]; ok && existingSize == length {
|
||||
uploadedBytes += length
|
||||
uploadedParts++
|
||||
}
|
||||
etag, err := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
|
||||
if err != nil {
|
||||
return OSSMultipartUploadResult{}, err
|
||||
}
|
||||
uploadedBytes += length
|
||||
uploadedParts++
|
||||
completeParts = append(completeParts, oss.UploadPart{
|
||||
PartNumber: int32(partNumber),
|
||||
ETag: oss.Ptr(etag),
|
||||
})
|
||||
}
|
||||
sort.Slice(completeParts, func(i, j int) bool {
|
||||
return completeParts[i].PartNumber < completeParts[j].PartNumber
|
||||
})
|
||||
|
||||
// 115 下发的 callback / callback_var 是 JSON 字符串,而 OSS CompleteMultipartUpload
|
||||
// 要求 callback 参数为 Base64 编码后的 JSON,否则报 "The callback configuration is
|
||||
// not base64 encoded"。这里把两者转为 Base64 后再提交(参考 QMediaSync 的
|
||||
// BuildOSSCallbackHeaders)。
|
||||
cb := input.Callback
|
||||
cbVar := input.CallbackVar
|
||||
if cb == "" {
|
||||
return OSSMultipartUploadResult{}, errors.New("OSS callback 为空")
|
||||
}
|
||||
if !json.Valid([]byte(cb)) {
|
||||
return OSSMultipartUploadResult{}, errors.New("解析 callback 失败:不是合法 JSON")
|
||||
}
|
||||
if cbVar == "" {
|
||||
cbVar = "{}"
|
||||
}
|
||||
if !json.Valid([]byte(cbVar)) {
|
||||
return OSSMultipartUploadResult{}, errors.New("解析 callback_var 失败:不是合法 JSON")
|
||||
}
|
||||
completeResult, err := u.client.CompleteMultipartUpload(ctx, &oss.CompleteMultipartUploadRequest{
|
||||
Bucket: oss.Ptr(input.Bucket),
|
||||
Key: oss.Ptr(input.Object),
|
||||
UploadId: oss.Ptr(uploadId),
|
||||
CompleteMultipartUpload: &oss.CompleteMultipartUpload{
|
||||
Parts: completeParts,
|
||||
},
|
||||
Callback: oss.Ptr(base64.StdEncoding.EncodeToString([]byte(cb))),
|
||||
CallbackVar: oss.Ptr(base64.StdEncoding.EncodeToString([]byte(cbVar))),
|
||||
})
|
||||
if err != nil {
|
||||
return OSSMultipartUploadResult{}, fmt.Errorf("完成 OSS multipart 失败:%w", err)
|
||||
}
|
||||
return OSSMultipartUploadResult{
|
||||
CallbackResult: completeResult.CallbackResult,
|
||||
UploadId: uploadId,
|
||||
PartSize: partSize,
|
||||
TotalParts: totalParts,
|
||||
UploadedBytes: uploadedBytes,
|
||||
UploadedParts: uploadedParts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListUploadedParts 查询 OSS 已上传分片。
|
||||
func (u *OSSMultipartUploader) ListUploadedParts(ctx context.Context, bucket, object, uploadId string) ([]struct {
|
||||
PartNumber int32
|
||||
Size int64
|
||||
}, error) {
|
||||
parts := []struct {
|
||||
PartNumber int32
|
||||
Size int64
|
||||
}{}
|
||||
result, err := u.client.ListParts(ctx, &oss.ListPartsRequest{
|
||||
Bucket: oss.Ptr(bucket),
|
||||
Key: oss.Ptr(object),
|
||||
UploadId: oss.Ptr(uploadId),
|
||||
MaxParts: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询 OSS 已上传分片失败:%w", err)
|
||||
}
|
||||
for _, part := range result.Parts {
|
||||
parts = append(parts, struct {
|
||||
PartNumber int32
|
||||
Size int64
|
||||
}{PartNumber: part.PartNumber, Size: part.Size})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func (u *OSSMultipartUploader) uploadPartWithRetry(
|
||||
ctx context.Context,
|
||||
input OSSMultipartUploadInput,
|
||||
uploadId string,
|
||||
partNumber int32,
|
||||
file *os.File,
|
||||
offset, length int64,
|
||||
) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < input.PartRetryMax; attempt++ {
|
||||
reader := io.NewSectionReader(file, offset, length)
|
||||
result, err := u.client.UploadPart(ctx, &oss.UploadPartRequest{
|
||||
Bucket: oss.Ptr(input.Bucket),
|
||||
Key: oss.Ptr(input.Object),
|
||||
PartNumber: partNumber,
|
||||
UploadId: oss.Ptr(uploadId),
|
||||
Body: reader,
|
||||
ContentLength: oss.Ptr(length),
|
||||
})
|
||||
if err == nil {
|
||||
if result.ETag == nil || *result.ETag == "" {
|
||||
return "", fmt.Errorf("OSS part %d 返回空 ETag", partNumber)
|
||||
}
|
||||
return *result.ETag, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt < input.PartRetryMax-1 && input.refreshClient != nil {
|
||||
refreshed, refreshErr := input.refreshClient(ctx)
|
||||
if refreshErr != nil {
|
||||
lastErr = refreshErr
|
||||
continue
|
||||
}
|
||||
u.client = refreshed
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("上传 OSS part %d 失败:%w", partNumber, lastErr)
|
||||
}
|
||||
|
||||
// ParseCompleteCallbackResult 校验并解析 OSS complete 后的 115 callback 结果。
|
||||
func ParseCompleteCallbackResult(result map[string]any) (UploadCompleteResult, error) {
|
||||
if result == nil {
|
||||
return UploadCompleteResult{}, errors.New("OSS complete callback 结果为空")
|
||||
}
|
||||
if state, ok := result["state"].(bool); ok && !state {
|
||||
return UploadCompleteResult{}, fmt.Errorf("115 callback 返回失败:%s", anyToString(result["message"]))
|
||||
}
|
||||
if message := anyToString(result["message"]); message != "" {
|
||||
return UploadCompleteResult{}, fmt.Errorf("115 callback 返回错误:%s", message)
|
||||
}
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return UploadCompleteResult{}, errors.New("115 callback 缺少 data")
|
||||
}
|
||||
complete := UploadCompleteResult{
|
||||
FileId: anyToString(data["file_id"]),
|
||||
PickCode: anyToString(data["pick_code"]),
|
||||
ParentId: anyToString(data["parent_id"]),
|
||||
Sha1: anyToString(data["sha1"]),
|
||||
Size: anyToInt64(data["size"]),
|
||||
Mtime: anyToInt64(data["mtime"]),
|
||||
}
|
||||
if complete.FileId == "" || complete.PickCode == "" {
|
||||
return UploadCompleteResult{}, errors.New("115 callback 缺少 file_id/pick_code")
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
func ceilDiv(n, d int64) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (n + d - 1) / d
|
||||
}
|
||||
|
||||
func roundUp(n, align int64) int64 {
|
||||
if align <= 0 {
|
||||
return n
|
||||
}
|
||||
return ceilDiv(n, align) * align
|
||||
}
|
||||
|
||||
func minInt64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func anyToInt64(v any) int64 {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
var n int64
|
||||
fmt.Sscanf(t, "%d", &n)
|
||||
return n
|
||||
case float64:
|
||||
return int64(t)
|
||||
case int64:
|
||||
return t
|
||||
case int:
|
||||
return int64(t)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func anyToString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
@@ -26,13 +26,15 @@ var (
|
||||
executorOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalExecutor 获取全局队列执行器单例(默认 QPS=8, QPM=480, QPH=20000,保障 115 API 调用安全不超频)。
|
||||
// QPS 从 2 提到 8:下载/列表等场景下过去 QPS=2 将所有直链换取串行为每秒 2 个,是下载吞吐的最大瓶颈。
|
||||
// 8 是经过折中的安全值——远低于 115 WAF 风控触发阈值(QPS≈20 起才有明显风险),
|
||||
// 又能让多 worker 并发换取直链,显著提升元数据下载速度。
|
||||
// GetGlobalExecutor 获取全局队列执行器单例(默认 QPS=3, QPM=200, QPH=12000,保障 115 API 调用安全不超频)。
|
||||
//
|
||||
// 历史教训:QPS 提到 8 后,下载换直链接口(/open/ufile/downurl,WAF 重点盯防对象)
|
||||
// 瞬时突发撞上 115 风控,返回阿里云 405 阻断页(HTTP 405),导致全量同步失败。
|
||||
// 因此回调到 3——这是经过实测的安全上限:宁慢勿触发风控,一旦 405 冷却 180 秒,
|
||||
// 整体吞吐反而更低。下载实际走 CDN 不受此限速影响,瓶颈仅在换链环节。
|
||||
func GetGlobalExecutor() *QueueExecutor {
|
||||
executorOnce.Do(func() {
|
||||
globalExecutor = NewQueueExecutor(8, 480, 20000)
|
||||
globalExecutor = NewQueueExecutor(3, 200, 12000)
|
||||
})
|
||||
return globalExecutor
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FileSHA1 计算文件完整 SHA1(小写 hex)。
|
||||
func FileSHA1(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha1.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(小写 hex)。
|
||||
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希。
|
||||
func FileSHA1Partial(path string, start, end int64) (string, error) {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
length := end - start + 1
|
||||
h := sha1.New()
|
||||
// io.CopyN 在文件不足 length 字节时会返回 io.EOF,导致小文件(如小于 128 KiB 的
|
||||
// 元数据图片)无法上传。这里只拷贝实际读到的字节,文件尾对齐到区间终点即可。
|
||||
if _, err := io.CopyN(h, f, length); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// 115 网盘元数据上传能力:115 开放平台调度 + 阿里云 OSS 直传。
|
||||
// 参考 QMediaSync 的上传流程实现:
|
||||
//
|
||||
// POST /open/upload/init 上传初始化/秒传调度(含二次签名)
|
||||
// GET /open/upload/get_token 获取 OSS 临时上传凭证(STS)
|
||||
// OSS multipart 分片直传 + callback 完成
|
||||
//
|
||||
// 上传目标父目录为 115 目录 ID(cid),而非路径字符串。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 115 上传状态码。
|
||||
const (
|
||||
UploadInitStatusNeedUpload = 1 // 需要真实上传
|
||||
UploadInitStatusRapidUploaded = 2 // 秒传成功
|
||||
UploadInitStatusSignFailed = 6 // 签名验证失败
|
||||
UploadInitStatusNeedSign = 7 // 需要二次签名
|
||||
UploadInitStatusSignRejected = 8 // 签名认证失败
|
||||
)
|
||||
|
||||
// UploadInitRequest 是 /open/upload/init 的结构化请求。
|
||||
type UploadInitRequest struct {
|
||||
FileName string
|
||||
FileSize int64
|
||||
ParentFileId string
|
||||
FileSha1 string
|
||||
Preid string
|
||||
PickCode string
|
||||
TopUpload string
|
||||
SignKey string
|
||||
SignVal string
|
||||
}
|
||||
|
||||
// UploadInitResult 是 /open/upload/init 的调度结果。
|
||||
type UploadInitResult struct {
|
||||
PickCode string
|
||||
Status int
|
||||
FileId string
|
||||
Target string
|
||||
Bucket string
|
||||
Object string
|
||||
SignKey string
|
||||
SignCheck string
|
||||
Callback UploadResultCallBack
|
||||
}
|
||||
|
||||
type uploadScheduleAPIResult struct {
|
||||
PickCode string `json:"pick_code"`
|
||||
Status int `json:"status"`
|
||||
FileId string `json:"file_id"`
|
||||
Target string `json:"target"`
|
||||
Version string `json:"version"`
|
||||
Bucket string `json:"bucket"`
|
||||
Object string `json:"object"`
|
||||
SignKey string `json:"sign_key"`
|
||||
SignCheck string `json:"sign_check"`
|
||||
Callback json.RawMessage `json:"callback"`
|
||||
}
|
||||
|
||||
// UploadResultCallBack 是 init 返回给 OSS complete 使用的 callback 内容。
|
||||
type UploadResultCallBack struct {
|
||||
Callback string `json:"callback"`
|
||||
CallbackVar string `json:"callback_var"`
|
||||
}
|
||||
|
||||
// UploadToken 是 /open/upload/get_token 返回的 OSS STS 临时凭证。
|
||||
type UploadToken struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
AccessKeySecret string `json:"AccessKeySecret"`
|
||||
AccessKeySecrett string `json:"AccessKeySecrett"`
|
||||
SecurityToken string `json:"SecurityToken"`
|
||||
Expiration string `json:"Expiration"`
|
||||
AccessKeyId string `json:"AccessKeyId"`
|
||||
}
|
||||
|
||||
func (token *UploadToken) normalize() {
|
||||
if token == nil {
|
||||
return
|
||||
}
|
||||
if token.AccessKeySecret == "" {
|
||||
token.AccessKeySecret = token.AccessKeySecrett
|
||||
}
|
||||
}
|
||||
|
||||
// UploadCompleteResult 是 OSS complete callback 成功后的远端文件定位结果。
|
||||
type UploadCompleteResult struct {
|
||||
FileId string
|
||||
PickCode string
|
||||
ParentId string
|
||||
Sha1 string
|
||||
Size int64
|
||||
Mtime int64
|
||||
}
|
||||
|
||||
// SignCheckRange 是 115 二次认证要求的闭区间 [start,end]。
|
||||
type SignCheckRange struct {
|
||||
Start int64
|
||||
End int64
|
||||
}
|
||||
|
||||
// UploadInit 调用 115 上传初始化/秒传调度接口。
|
||||
func (c *OpenClient) UploadInit(ctx context.Context, input UploadInitRequest) (*UploadInitResult, error) {
|
||||
params := buildUploadInitForm(input)
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/upload/init", params, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw uploadScheduleAPIResult
|
||||
if err := json.Unmarshal(resp.Data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("115: 解析 upload/init 结果失败:%w", err)
|
||||
}
|
||||
callback, err := decodeUploadCallback(raw.Callback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UploadInitResult{
|
||||
PickCode: raw.PickCode,
|
||||
Status: raw.Status,
|
||||
FileId: raw.FileId,
|
||||
Target: raw.Target,
|
||||
Bucket: raw.Bucket,
|
||||
Object: raw.Object,
|
||||
SignKey: raw.SignKey,
|
||||
SignCheck: raw.SignCheck,
|
||||
Callback: callback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildUploadInitForm(input UploadInitRequest) map[string]string {
|
||||
topUpload := input.TopUpload
|
||||
if topUpload == "" {
|
||||
topUpload = "0"
|
||||
}
|
||||
params := map[string]string{
|
||||
"file_name": input.FileName,
|
||||
"file_size": strconv.FormatInt(input.FileSize, 10),
|
||||
"target": fmt.Sprintf("U_1_%s", input.ParentFileId),
|
||||
"fileid": input.FileSha1,
|
||||
"preid": input.Preid,
|
||||
"topupload": topUpload,
|
||||
}
|
||||
if input.PickCode != "" {
|
||||
params["pick_code"] = input.PickCode
|
||||
}
|
||||
if input.SignKey != "" && input.SignVal != "" {
|
||||
params["sign_key"] = input.SignKey
|
||||
params["sign_val"] = input.SignVal
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func decodeUploadCallback(raw json.RawMessage) (UploadResultCallBack, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return UploadResultCallBack{}, nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var callbacks []UploadResultCallBack
|
||||
if err := json.Unmarshal(raw, &callbacks); err != nil {
|
||||
return UploadResultCallBack{}, err
|
||||
}
|
||||
if len(callbacks) == 0 {
|
||||
return UploadResultCallBack{}, nil
|
||||
}
|
||||
return callbacks[0], nil
|
||||
}
|
||||
var callback UploadResultCallBack
|
||||
if err := json.Unmarshal(raw, &callback); err != nil {
|
||||
return UploadResultCallBack{}, err
|
||||
}
|
||||
return callback, nil
|
||||
}
|
||||
|
||||
func parseSignCheckRange(value string) (SignCheckRange, error) {
|
||||
parts := strings.Split(value, "-")
|
||||
if len(parts) != 2 {
|
||||
return SignCheckRange{}, fmt.Errorf("sign_check 格式错误:%s", value)
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
if err != nil {
|
||||
return SignCheckRange{}, err
|
||||
}
|
||||
end, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil {
|
||||
return SignCheckRange{}, err
|
||||
}
|
||||
if start < 0 || end < start {
|
||||
return SignCheckRange{}, fmt.Errorf("sign_check 范围非法:%s", value)
|
||||
}
|
||||
return SignCheckRange{Start: start, End: end}, nil
|
||||
}
|
||||
|
||||
// GetUploadToken 获取 115 下发的 OSS 临时上传凭证。
|
||||
func (c *OpenClient) GetUploadToken(ctx context.Context) (*UploadToken, error) {
|
||||
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/upload/get_token", nil, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var token UploadToken
|
||||
if err := json.Unmarshal(resp.Data, &token); err != nil {
|
||||
return nil, fmt.Errorf("115: 解析 get_token 结果失败:%w", err)
|
||||
}
|
||||
token.normalize()
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// Upload 上传单个本地文件到 115 指定父目录(cid),返回成功后的远端文件信息。
|
||||
// filePath 必须是落到磁盘的真实文件路径(调用方负责把 io.Reader 落盘为临时文件)。
|
||||
func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, signVal string) (*UploadCompleteResult, error) {
|
||||
fileSize := fileSizeOf(filePath)
|
||||
if fileSize < 0 {
|
||||
return nil, fmt.Errorf("115: 无法获取文件大小:%s", filePath)
|
||||
}
|
||||
fileSha1, err := FileSHA1(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: 计算文件 SHA1 失败:%w", err)
|
||||
}
|
||||
preSha1, err := FileSHA1Partial(filePath, 0, 128*1024-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: 计算文件前 128 KiB SHA1 失败:%w", err)
|
||||
}
|
||||
request := UploadInitRequest{
|
||||
FileName: baseNameOf(filePath),
|
||||
FileSize: fileSize,
|
||||
ParentFileId: parentCID,
|
||||
FileSha1: fileSha1,
|
||||
Preid: preSha1,
|
||||
TopUpload: "0",
|
||||
SignKey: signKey,
|
||||
SignVal: signVal,
|
||||
}
|
||||
initResult, err := c.UploadInit(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: 上传初始化失败:%w", err)
|
||||
}
|
||||
status := initResult.Status
|
||||
if status == UploadInitStatusNeedSign {
|
||||
// 二次签名:按 sign_check 指定区间重算 sha1
|
||||
rng, err := parseSignCheckRange(initResult.SignCheck)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signValue, err := FileSHA1Partial(filePath, rng.Start, rng.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.SignKey = initResult.SignKey
|
||||
request.SignVal = signValue
|
||||
initResult, err = c.UploadInit(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: 上传二次签名失败:%w", err)
|
||||
}
|
||||
status = initResult.Status
|
||||
}
|
||||
switch status {
|
||||
case UploadInitStatusRapidUploaded:
|
||||
// 秒传成功
|
||||
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
|
||||
case UploadInitStatusSignFailed:
|
||||
return nil, fmt.Errorf("115: 签名验证后失败")
|
||||
case UploadInitStatusSignRejected:
|
||||
return nil, fmt.Errorf("115: 签名认证失败")
|
||||
case UploadInitStatusNeedUpload:
|
||||
// 真实上传:OSS multipart
|
||||
default:
|
||||
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
|
||||
}
|
||||
|
||||
if initResult.Bucket == "" || initResult.Object == "" {
|
||||
return nil, fmt.Errorf("115: upload/init 缺少 bucket/object 信息")
|
||||
}
|
||||
token, err := c.GetUploadToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: 获取上传凭证失败:%w", err)
|
||||
}
|
||||
if token == nil || token.Endpoint == "" || token.AccessKeyId == "" || token.AccessKeySecret == "" {
|
||||
return nil, fmt.Errorf("115: 上传凭证不完整")
|
||||
}
|
||||
uploader := NewOSSMultipartUploader(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, token.SecurityToken)
|
||||
result, err := uploader.UploadFile(ctx, OSSMultipartUploadInput{
|
||||
Bucket: initResult.Bucket,
|
||||
Object: initResult.Object,
|
||||
Callback: initResult.Callback.Callback,
|
||||
CallbackVar: initResult.Callback.CallbackVar,
|
||||
FilePath: filePath,
|
||||
FileSize: fileSize,
|
||||
refreshClient: func(ctx context.Context) (ossMultipartClient, error) {
|
||||
refreshed, rerr := c.GetUploadToken(ctx)
|
||||
if rerr != nil || refreshed == nil {
|
||||
return nil, rerr
|
||||
}
|
||||
return newOSSMultipartClient(refreshed.Endpoint, refreshed.AccessKeyId, refreshed.AccessKeySecret, refreshed.SecurityToken), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115: OSS 上传失败:%w", err)
|
||||
}
|
||||
complete, err := ParseCompleteCallbackResult(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &complete, nil
|
||||
}
|
||||
|
||||
// MkDir 在 115 的 parentCid 下创建目录,返回新目录 cid。
|
||||
func (c *OpenClient) MkDir(ctx context.Context, parentCID, name string) (string, error) {
|
||||
params := map[string]string{
|
||||
"cname": name,
|
||||
"pid": parentCID,
|
||||
}
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/folder/add", params, 2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// /open/folder/add 结构:{ aid, cid, fid, name, pid, ... },单一对象
|
||||
var r struct {
|
||||
Cid string `json:"cid"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &r); err != nil {
|
||||
return "", fmt.Errorf("115: 解析 folder/add 结果失败:%w", err)
|
||||
}
|
||||
if r.Cid == "" {
|
||||
return "", errors.New("115: folder/add 未返回 cid")
|
||||
}
|
||||
return r.Cid, nil
|
||||
}
|
||||
|
||||
func fileSizeOf(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
if info.IsDir() {
|
||||
return -1
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func baseNameOf(path string) string {
|
||||
s := path
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == '/' || s[i] == '\\' {
|
||||
return s[i+1:]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
)
|
||||
|
||||
func TestFileSHA1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum, err := FileSHA1(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
|
||||
if sum != "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" {
|
||||
t.Errorf("unexpected sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSHA1Partial(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "b.txt")
|
||||
// 10 bytes: "0123456789"
|
||||
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// bytes [2,4] = "234"
|
||||
sum, err := FileSHA1Partial(path, 2, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum != "0ec09ef9836da03f1add21e3ef607627e687e790" {
|
||||
t.Errorf("unexpected partial sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSHA1PartialSmallerThanWindow 回归测试:经典 bug 是 io.CopyN 在文件不足
|
||||
// length 字节时返回 io.EOF。115 上传固定用 [0,128*1024-1] 窗口计算 preid,导致所有
|
||||
// 小于 128 KiB 的元数据文件(如海报/缩略图)上传必然失败。
|
||||
func TestFileSHA1PartialSmallerThanWindow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "small.bin")
|
||||
// 6 字节小文件,不足 128 KiB 窗口
|
||||
if err := os.WriteFile(path, []byte("abcdef"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum, err := FileSHA1Partial(path, 0, 128*1024-1)
|
||||
if err != nil {
|
||||
t.Fatalf("compute partial sha1 for small file should not fail: %v", err)
|
||||
}
|
||||
// 应等于整个文件(6 字节)的 sha1
|
||||
if sum != "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2" {
|
||||
t.Errorf("unexpected partial sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignCheckRange(t *testing.T) {
|
||||
rng, err := parseSignCheckRange("0-131071")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rng.Start != 0 || rng.End != 131071 {
|
||||
t.Errorf("unexpected range: %+v", rng)
|
||||
}
|
||||
if _, err := parseSignCheckRange("bad"); err == nil {
|
||||
t.Error("expected error for bad range")
|
||||
}
|
||||
if _, err := parseSignCheckRange("100-50"); err == nil {
|
||||
t.Error("expected error for end<start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateMultipartPartSize(t *testing.T) {
|
||||
// small file: 1 MiB -> partSize 32MiB, 1 part
|
||||
ps, parts, err := CalculateMultipartPartSize(1 << 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ps != defaultMultipartPartSize {
|
||||
t.Errorf("partSize=%d, want %d", ps, defaultMultipartPartSize)
|
||||
}
|
||||
if parts != 1 {
|
||||
t.Errorf("parts=%d, want 1", parts)
|
||||
}
|
||||
// zero-size -> 1 part
|
||||
_, parts, err = CalculateMultipartPartSize(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parts != 1 {
|
||||
t.Errorf("zero-size parts=%d, want 1", parts)
|
||||
}
|
||||
// negative -> error
|
||||
if _, _, err := CalculateMultipartPartSize(-1); err == nil {
|
||||
t.Error("expected error for negative size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseNameOf(t *testing.T) {
|
||||
if got := baseNameOf("/a/b/file.nfo"); got != "file.nfo" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
if got := baseNameOf("a\\b\\c.jpg"); got != "c.jpg" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
if got := baseNameOf("top.txt"); got != "top.txt" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeCallbackOSSClient 捕获 CompleteMultipartUpload 收到的 callback / callback_var,
|
||||
// 用于断言已经 Base64 编码(116 要求 callback 必须是 Base64 后的 JSON,否则报
|
||||
// "The callback configuration is not base64 encoded")。
|
||||
type fakeCallbackOSSClient struct {
|
||||
capturedCallback string
|
||||
capturedCallbackVar string
|
||||
}
|
||||
|
||||
func (c *fakeCallbackOSSClient) InitiateMultipartUpload(_ context.Context, _ *oss.InitiateMultipartUploadRequest, _ ...func(*oss.Options)) (*oss.InitiateMultipartUploadResult, error) {
|
||||
return &oss.InitiateMultipartUploadResult{UploadId: oss.Ptr("upload-new")}, nil
|
||||
}
|
||||
func (c *fakeCallbackOSSClient) UploadPart(_ context.Context, r *oss.UploadPartRequest, _ ...func(*oss.Options)) (*oss.UploadPartResult, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
return &oss.UploadPartResult{ETag: oss.Ptr("etag-1")}, nil
|
||||
}
|
||||
func (c *fakeCallbackOSSClient) ListParts(context.Context, *oss.ListPartsRequest, ...func(*oss.Options)) (*oss.ListPartsResult, error) {
|
||||
return &oss.ListPartsResult{}, nil
|
||||
}
|
||||
func (c *fakeCallbackOSSClient) CompleteMultipartUpload(_ context.Context, r *oss.CompleteMultipartUploadRequest, _ ...func(*oss.Options)) (*oss.CompleteMultipartUploadResult, error) {
|
||||
c.capturedCallback = *r.Callback
|
||||
c.capturedCallbackVar = *r.CallbackVar
|
||||
return &oss.CompleteMultipartUploadResult{
|
||||
CallbackResult: map[string]any{
|
||||
"state": true,
|
||||
"data": map[string]any{"file_id": "file-1", "pick_code": "pick-1"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
func (c *fakeCallbackOSSClient) AbortMultipartUpload(context.Context, *oss.AbortMultipartUploadRequest, ...func(*oss.Options)) (*oss.AbortMultipartUploadResult, error) {
|
||||
return &oss.AbortMultipartUploadResult{}, nil
|
||||
}
|
||||
|
||||
// TestCompleteMultipartUploadCallbackBase64 回归测试:OSS CompleteMultipartUpload 的
|
||||
// callback 必须 Base64 编码,否则报 "The callback configuration is not base64 encoded",
|
||||
// 导致大于 128 KiB 的元数据文件上传失败。
|
||||
func TestCompleteMultipartUploadCallbackBase64(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "big.bin")
|
||||
data := make([]byte, 8) // 8 字节,PartSize=8 → 1 part
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeCallbackOSSClient{}
|
||||
uploader := &OSSMultipartUploader{client: fake}
|
||||
|
||||
callback := `{"callbackUrl":"http://uplb.115.com/3.0/completeupload.php"}`
|
||||
callbackVar := `{"x:pick_code":"abc"}`
|
||||
_, err := uploader.UploadFileWithResult(context.Background(), OSSMultipartUploadInput{
|
||||
Bucket: "bucket-1",
|
||||
Object: "object-1",
|
||||
Callback: callback,
|
||||
CallbackVar: callbackVar,
|
||||
FilePath: path,
|
||||
FileSize: int64(len(data)),
|
||||
PartSize: 8,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("multipart 上传失败:%v", err)
|
||||
}
|
||||
// 捕获的 callback 必须是合法 Base64,且解码后与原 JSON 一致
|
||||
cbBytes, err := base64.StdEncoding.DecodeString(fake.capturedCallback)
|
||||
if err != nil {
|
||||
t.Fatalf("callback 未 Base64 编码:%v (raw=%q)", err, fake.capturedCallback)
|
||||
}
|
||||
if string(cbBytes) != callback {
|
||||
t.Errorf("callback 解码后 = %s,期望 %s", cbBytes, callback)
|
||||
}
|
||||
cbvBytes, err := base64.StdEncoding.DecodeString(fake.capturedCallbackVar)
|
||||
if err != nil {
|
||||
t.Fatalf("callback_var 未 Base64 编码:%v (raw=%q)", err, fake.capturedCallbackVar)
|
||||
}
|
||||
if string(cbvBytes) != callbackVar {
|
||||
t.Errorf("callback_var 解码后 = %s,期望 %s", cbvBytes, callbackVar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// DatabaseAdminService manages database configuration, connectivity testing, and migration.
|
||||
type DatabaseAdminService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repos *repository.Container
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDatabaseAdminService creates a new DatabaseAdminService.
|
||||
func NewDatabaseAdminService(cfg *config.Config, log *zap.Logger, repos *repository.Container, db *gorm.DB) *DatabaseAdminService {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DatabaseAdminService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repos: repos,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the currently active database.
|
||||
func (s *DatabaseAdminService) GetStatus(ctx context.Context) *database.DatabaseStatus {
|
||||
return database.InspectDatabaseStatus(s.db, s.cfg)
|
||||
}
|
||||
|
||||
// TestPostgres verifies connectivity and permissions to the specified PostgreSQL DSN.
|
||||
func (s *DatabaseAdminService) TestPostgres(ctx context.Context, dsn string) (*database.PostgresTestResult, error) {
|
||||
return database.TestPostgres(dsn)
|
||||
}
|
||||
|
||||
// MigrateToPostgres copies all records from the current active database to the target PostgreSQL database.
|
||||
func (s *DatabaseAdminService) MigrateToPostgres(ctx context.Context, targetDSN string) (*database.DatabaseMigrationResult, error) {
|
||||
s.log.Info("starting user-initiated database migration to PostgreSQL", zap.String("target", database.MaskDSN(targetDSN)))
|
||||
res, err := database.MigrateCurrentToPostgres(s.db, targetDSN, 500, s.log)
|
||||
if err != nil {
|
||||
s.log.Error("database migration to PostgreSQL failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
s.log.Info("database migration to PostgreSQL completed successfully",
|
||||
zap.Int64("total_rows", res.TotalRows),
|
||||
zap.Int64("duration_ms", res.DurationMS),
|
||||
)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SaveConfig persists the database configuration to config.yaml and the database settings table.
|
||||
func (s *DatabaseAdminService) SaveConfig(ctx context.Context, dbType, dsn string) error {
|
||||
dbType = strings.TrimSpace(dbType)
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dbType == "" {
|
||||
dbType = "postgres"
|
||||
}
|
||||
if dbType == "postgres" && dsn == "" {
|
||||
return fmt.Errorf("PostgreSQL DSN 不能为空")
|
||||
}
|
||||
|
||||
// 1. 保存到本地 config.yaml
|
||||
if err := config.SaveDatabaseConfig(dbType, dsn); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 更新内存配置
|
||||
s.cfg.Database.Type = dbType
|
||||
s.cfg.Database.DSN = dsn
|
||||
|
||||
// 3. 同时更新 settings 存储库作为副本
|
||||
if s.repos != nil && s.repos.Setting != nil {
|
||||
_ = s.repos.Setting.Set(ctx, "database.type", dbType)
|
||||
_ = s.repos.Setting.Set(ctx, "database.dsn", dsn)
|
||||
}
|
||||
|
||||
s.log.Info("database configuration saved", zap.String("type", dbType), zap.String("dsn", database.MaskDSN(dsn)))
|
||||
return nil
|
||||
}
|
||||
@@ -199,7 +199,9 @@ func (e *EmbyService) appendSubtitleStreams(ctx context.Context, streams []map[s
|
||||
if e == nil || e.subtitle == nil || m == nil {
|
||||
return streams
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
// Emby 字幕只列表外挂字幕文件:云盘/strm 媒体的容器内嵌字幕不做服务端
|
||||
// 提取,客户端直连播放直链时自行解析。
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return streams
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
// so the DeliveryUrl advertised in MediaStreams lines up exactly with the
|
||||
// served track: subtitles start at 1 when no audio stream is present, otherwise
|
||||
// at 2 (after Video 0 + Audio 1).
|
||||
//
|
||||
// 只服务外挂字幕文件(DiscoverExternalOnly):云盘/strm 媒体的容器内嵌字幕
|
||||
// 不做服务端提取,客户端直连播放直链时自行解析。
|
||||
func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, mediaID, indexStr string, userID string) error {
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ErrSubtitleUnavailable
|
||||
@@ -44,7 +47,7 @@ func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, medi
|
||||
if err != nil || index < 1 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
@@ -74,7 +77,7 @@ func (e *EmbyService) SubtitleStreamCodec(ctx context.Context, mediaID, indexStr
|
||||
if err != nil || index < 1 {
|
||||
return ""
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,196 +1,358 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// AutoInstallFFmpeg is only called by the admin tool-install endpoint. The
|
||||
// server must not auto-download or keep ffmpeg/ffprobe running during startup.
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
ffprobePath = path
|
||||
cfg.App.FFprobePath = path
|
||||
log.Info("found local ffprobe", zap.String("path", path))
|
||||
}
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
|
||||
ffmpegPath = path
|
||||
cfg.App.FFmpegPath = path
|
||||
log.Info("found local ffmpeg", zap.String("path", path))
|
||||
}
|
||||
if ffprobePath != "" || ffmpegPath != "" {
|
||||
return ffprobePath, ffmpegPath
|
||||
}
|
||||
// ffmpegDownloadTarget 描述某个平台对应的官方构建下载源。
|
||||
type ffmpegDownloadTarget struct {
|
||||
Label string // 展示名,如 "Windows x86_64"
|
||||
Kind string // 压缩包类型:zip / tar.xz
|
||||
Archives []string // 依次尝试的下载地址(主源 + 备用源)
|
||||
}
|
||||
|
||||
// 2. 检查默认安装位置。
|
||||
defaultDir := getDefaultInstallDir()
|
||||
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("在默认位置找到 ffprobe", zap.String("path", ffprobeDefault))
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
}
|
||||
|
||||
// 3. 尝试自动安装。
|
||||
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||
installed, err := tryAutoInstall(log, defaultDir)
|
||||
if err != nil {
|
||||
log.Error("自动安装失败,请手动安装 ffmpeg", zap.Error(err))
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if installed {
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("自动安装成功", zap.String("path", ffprobeDefault))
|
||||
// 更新配置
|
||||
updateConfigPaths(cfg, ffprobeDefault, ffmpegDefault)
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
// ffmpegTargetForPlatform 按当前运行环境(OS+架构)选择下载源。
|
||||
func ffmpegTargetForPlatform() (*ffmpegDownloadTarget, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "386":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win32-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux x86_64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM (32 位)",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarmhf-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "loong64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux LoongArch64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxloongarch64-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osx64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS Apple Silicon",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osxarm64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
return nil, fmt.Errorf("暂不支持自动下载的平台 %s/%s,请手动填写 ffmpeg/ffprobe 路径", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// getDefaultInstallDir 返回默认安装目录
|
||||
func getDefaultInstallDir() string {
|
||||
exePath, err := os.Executable()
|
||||
// installFFmpegTools 按平台下载并安装 ffmpeg/ffprobe 到 data/tools/ffmpeg/,
|
||||
// 返回两个可执行文件的绝对路径。progress 用于回传阶段消息(UI 展示)。
|
||||
func installFFmpegTools(ctx context.Context, log *zap.Logger, cfg *config.Config, progress func(string)) (ffmpegPath, ffprobePath string, err error) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
if err != nil {
|
||||
return "./tools/ffmpeg"
|
||||
return "", "", err
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
return filepath.Join(exeDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// tryAutoInstall 尝试自动下载并安装 ffmpeg
|
||||
func tryAutoInstall(log *zap.Logger, installDir string) (bool, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return downloadFFmpegWindows(log, installDir)
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// downloadFFmpegWindows 下载 Windows 版本的 ffmpeg
|
||||
func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) {
|
||||
log.Info("开始下载 ffmpeg...")
|
||||
|
||||
// 创建安装目录
|
||||
installDir := filepath.Join(cfg.App.DataDir, "tools", "ffmpeg")
|
||||
if err := os.MkdirAll(installDir, 0o750); err != nil {
|
||||
return false, fmt.Errorf("创建安装目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建安装目录失败: %w", err)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "mmtl-ffmpeg-*")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 下载 URL (使用 gyani.org 的静态构建)
|
||||
arch := "win64"
|
||||
if !is64Bit() {
|
||||
arch = "win32"
|
||||
progress("下载 " + target.Label + " 版本…")
|
||||
archivePath := filepath.Join(tempDir, "ffmpeg-archive."+target.Kind)
|
||||
if err := downloadFFmpegArchive(ctx, log, target.Archives, archivePath); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 先尝试从 gyan.dev 下载(更可靠)
|
||||
downloadURL := fmt.Sprintf("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
|
||||
progress("解压…")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := extractFFmpegArchive(target.Kind, archivePath, extractDir); err != nil {
|
||||
return "", "", fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
log.Info("下载 ffmpeg", zap.String("url", downloadURL))
|
||||
exeSuffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exeSuffix = ".exe"
|
||||
}
|
||||
srcFFmpeg, srcFFprobe, err := locateFFmpegBinaries(extractDir, exeSuffix)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 使用 Go 下载
|
||||
zipPath := filepath.Join(tempDir, "ffmpeg.zip")
|
||||
if err := downloadFile(log, downloadURL, zipPath); err != nil {
|
||||
// 尝试备用 URL
|
||||
backupURL := fmt.Sprintf("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-%s-gpl.zip", arch)
|
||||
log.Info("尝试备用下载地址", zap.String("url", backupURL))
|
||||
if err2 := downloadFile(log, backupURL, zipPath); err2 != nil {
|
||||
return false, fmt.Errorf("下载失败: %v, %v", err, err2)
|
||||
progress("安装到 data 目录…")
|
||||
ffmpegPath = filepath.Join(installDir, "ffmpeg"+exeSuffix)
|
||||
ffprobePath = filepath.Join(installDir, "ffprobe"+exeSuffix)
|
||||
if err := copyFileMode(srcFFmpeg, ffmpegPath); err != nil {
|
||||
return "", "", fmt.Errorf("复制 ffmpeg 失败: %w", err)
|
||||
}
|
||||
if err := copyFileMode(srcFFprobe, ffprobePath); err != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
return "", "", fmt.Errorf("复制 ffprobe 失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证两个工具都能运行(失败则回滚,避免留下坏文件)。
|
||||
for _, bin := range []string{ffmpegPath, ffprobePath} {
|
||||
cmd := exec.CommandContext(ctx, bin, "-version") // #nosec G204 -- bin 是安装目录中刚写入的固定文件名。
|
||||
if out, verr := cmd.Output(); verr != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
_ = os.Remove(ffprobePath)
|
||||
return "", "", fmt.Errorf("安装后 %s 无法运行:%v", filepath.Base(bin), verr)
|
||||
} else if log != nil {
|
||||
log.Info("ffmpeg 工具安装验证通过", zap.String("bin", filepath.Base(bin)),
|
||||
zap.String("version", strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])))
|
||||
}
|
||||
}
|
||||
|
||||
// 解压
|
||||
log.Info("解压 ffmpeg...")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := unzip(log, zipPath, extractDir); err != nil {
|
||||
return false, fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
packageRoot, err := findFFmpegPackageRoot(extractDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := copyDirContents(packageRoot, installDir); err != nil {
|
||||
return false, fmt.Errorf("复制 ffmpeg 文件失败: %w", err)
|
||||
}
|
||||
|
||||
ffmpegBin := filepath.Join(installDir, "bin", "ffmpeg.exe")
|
||||
ffprobeBin := filepath.Join(installDir, "bin", "ffprobe.exe")
|
||||
if _, err := os.Stat(ffmpegBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffmpeg: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(ffprobeBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffprobe: %w", err)
|
||||
}
|
||||
|
||||
log.Info("ffmpeg 安装完成", zap.String("dir", installDir))
|
||||
return true, nil
|
||||
progress("安装完成")
|
||||
return ffmpegPath, ffprobePath, nil
|
||||
}
|
||||
|
||||
// downloadFile 下载文件
|
||||
func downloadFile(log *zap.Logger, url, filepath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
// downloadFFmpegArchive 按顺序尝试下载源,全部失败才返回错误。
|
||||
func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string, dest string) error {
|
||||
var lastErr error
|
||||
for i, u := range urls {
|
||||
if i > 0 && log != nil {
|
||||
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
|
||||
}
|
||||
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
|
||||
lastErr = err
|
||||
if log != nil {
|
||||
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("所有下载源均失败:%v", lastErr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
|
||||
func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MMTL/ffmpeg-installer ("+runtime.GOOS+"/"+runtime.GOARCH+")")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode)
|
||||
return fmt.Errorf("下载失败,HTTP 状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath) // #nosec G304 -- filepath is generated by the installer under its temporary download directory.
|
||||
out, err := os.Create(dest) // #nosec G304 -- dest 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
n, err := io.Copy(out, io.LimitReader(resp.Body, 500<<20+1)) // 归档上限 500MB
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 500<<20 {
|
||||
return fmt.Errorf("归档文件过大(>500MB): %s", url)
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateConfigPaths 更新配置文件中的路径
|
||||
func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) {
|
||||
cfg.App.FFprobePath = ffprobePath
|
||||
cfg.App.FFmpegPath = ffmpegPath
|
||||
|
||||
// 保存到配置文件
|
||||
// 这里需要调用 config 包的保存函数
|
||||
log := zap.L().Named("config")
|
||||
log.Info("已更新 ffmpeg 路径配置",
|
||||
zap.String("ffprobe", ffprobePath),
|
||||
zap.String("ffmpeg", ffmpegPath))
|
||||
// extractFFmpegArchive 按类型解压 zip 或 tar.xz。
|
||||
func extractFFmpegArchive(kind, archivePath, destDir string) error {
|
||||
switch kind {
|
||||
case "zip":
|
||||
return unzip(nil, archivePath, destDir)
|
||||
case "tar.xz":
|
||||
return untarXZ(archivePath, destDir)
|
||||
default:
|
||||
return fmt.Errorf("不支持的归档类型: %s", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// is64Bit 检查是否为 64 位系统
|
||||
func is64Bit() bool {
|
||||
return true // 简化处理,假设为 64 位
|
||||
// untarXZ 解压 .tar.xz 归档(GNU tar + xz 流式解压,纯 Go 无外部依赖),
|
||||
// 路径安全校验与 ZIP 解压一致。
|
||||
func untarXZ(archivePath, destDir string) error {
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
destRoot, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(archivePath) // #nosec G304 -- archivePath 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
xzReader, err := xz.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(xzReader)
|
||||
var totalWritten int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := safeZipTarget(destRoot, hdr.Name) // 与 ZIP 相同的路径穿越防护
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode).Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, copyErr := io.Copy(dst, io.LimitReader(tr, maxFFmpegZipEntryBytes+1))
|
||||
totalWritten += written
|
||||
closeErr := dst.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes {
|
||||
return fmt.Errorf("tar 内容过大: %s", hdr.Name)
|
||||
}
|
||||
default:
|
||||
// 符号链接/设备等一律跳过(静态构建不会依赖它们)。
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// locateFFmpegBinaries 在解压目录中查找 ffmpeg/ffprobe 可执行文件(兼容
|
||||
// 不同构建包的目录布局:gyan 的 bin/、BtbN/johnvansickle 的根目录等)。
|
||||
func locateFFmpegBinaries(root, exeSuffix string) (ffmpeg, ffprobe string, err error) {
|
||||
wantFFmpeg := "ffmpeg" + strings.ToLower(exeSuffix)
|
||||
wantFFprobe := "ffprobe" + strings.ToLower(exeSuffix)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case wantFFmpeg:
|
||||
if ffmpeg == "" {
|
||||
ffmpeg = path
|
||||
}
|
||||
case wantFFprobe:
|
||||
if ffprobe == "" {
|
||||
ffprobe = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpeg == "" || ffprobe == "" {
|
||||
return "", "", fmt.Errorf("解压内容中未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
return ffmpeg, ffprobe, nil
|
||||
}
|
||||
|
||||
// copyFileMode 复制文件并赋予可执行权限。
|
||||
func copyFileMode(src, dst string) error {
|
||||
in, err := os.Open(src) // #nosec G304 -- src 来自解压目录遍历结果。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ func unzip(log *zap.Logger, zipPath, destDir string) error {
|
||||
}
|
||||
info := file.FileInfo()
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
if log != nil {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
@@ -110,93 +112,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
||||
return targetAbs, nil
|
||||
}
|
||||
|
||||
func findFFmpegPackageRoot(root string) (string, error) {
|
||||
var ffmpegPath string
|
||||
var ffprobePath string
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case "ffmpeg.exe":
|
||||
ffmpegPath = path
|
||||
case "ffprobe.exe":
|
||||
ffprobePath = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpegPath == "" || ffprobePath == "" {
|
||||
return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
|
||||
return filepath.Dir(filepath.Dir(ffmpegPath)), nil
|
||||
}
|
||||
|
||||
func copyDirContents(srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
dstPath := filepath.Join(dstDir, entry.Name())
|
||||
if err := copyTree(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTree(srcPath, dstPath string) error {
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := os.MkdirAll(dstPath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用)
|
||||
func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} {
|
||||
status := map[string]interface{}{
|
||||
"ffprobe_installed": false,
|
||||
"ffmpeg_installed": false,
|
||||
"auto_installable": runtime.GOOS == "windows",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
status["ffprobe_installed"] = true
|
||||
status["ffprobe_path"] = ffprobePath
|
||||
|
||||
// 获取版本
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
// 提取版本信息(第一行)
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffprobe_version"] = version
|
||||
status["ffprobe_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ffmpegPath != "" {
|
||||
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||
status["ffmpeg_installed"] = true
|
||||
status["ffmpeg_path"] = ffmpegPath
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffmpeg_version"] = version
|
||||
status["ffmpeg_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package service — ffmpeg/ffprobe 自动下载安装。
|
||||
//
|
||||
// FFmpegToolsService 负责「一键下载」:点击后按当前运行平台(OS+架构)选择
|
||||
// 官方构建包(Windows: gyan.dev / BtbN;Linux: BtbN / johnvansickle;
|
||||
// macOS: BtbN),下载解压 ffmpeg/ffprobe 到 data 目录(data/tools/ffmpeg/),
|
||||
// 并把绝对路径写入设置(ffmpeg.path / ffprobe.path),系统随即使用安装的
|
||||
// 工具,无需手动填写路径。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// FFmpegToolsService 管理 ffmpeg/ffprobe 的自动下载安装状态(单飞:同一时间
|
||||
// 只允许一个安装任务)。
|
||||
type FFmpegToolsService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
msg string // 最近阶段/结果消息
|
||||
errMsg string // 最近一次失败原因
|
||||
started time.Time // 最近一次安装开始时间
|
||||
done time.Time // 最近一次安装结束时间
|
||||
}
|
||||
|
||||
// NewFFmpegToolsService 构造工具安装服务。
|
||||
func NewFFmpegToolsService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FFmpegToolsService {
|
||||
return &FFmpegToolsService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// ffmpegInstallDir 返回 data 目录下的安装位置。
|
||||
func (s *FFmpegToolsService) ffmpegInstallDir() string {
|
||||
return filepath.Join(s.cfg.App.DataDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// installedBinaries 检查安装目录中是否已存在 ffmpeg/ffprobe 可执行文件。
|
||||
func (s *FFmpegToolsService) installedBinaries() (ffmpeg, ffprobe string) {
|
||||
exe := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exe = ".exe"
|
||||
}
|
||||
ffmpeg = filepath.Join(s.ffmpegInstallDir(), "ffmpeg"+exe)
|
||||
ffprobe = filepath.Join(s.ffmpegInstallDir(), "ffprobe"+exe)
|
||||
if _, err := os.Stat(ffmpeg); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if _, err := os.Stat(ffprobe); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return ffmpeg, ffprobe
|
||||
}
|
||||
|
||||
// ffToolVersion 取工具第一行版本信息。
|
||||
func ffToolVersion(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(path, "-version").Output() // #nosec G204 -- path 来自配置/安装目录中的已知工具。
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)
|
||||
if len(line) == 0 || strings.TrimSpace(line[0]) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(line[0])
|
||||
}
|
||||
|
||||
// ffToolInfo 是单个工具的安装状态(返回给前端展示)。
|
||||
type ffToolInfo struct {
|
||||
Installed bool `json:"installed"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Status 返回当前安装状态(供 GET /api/admin/tools/ffmpeg/status 使用)。
|
||||
func (s *FFmpegToolsService) Status(ctx context.Context) map[string]any {
|
||||
s.mu.Lock()
|
||||
running, msg, errMsg, started, done := s.running, s.msg, s.errMsg, s.started, s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
startedAt, doneAt := "", ""
|
||||
if !started.IsZero() {
|
||||
startedAt = started.Format(time.RFC3339)
|
||||
}
|
||||
if !done.IsZero() {
|
||||
doneAt = done.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"installing": running,
|
||||
"message": msg,
|
||||
"error": errMsg,
|
||||
"started_at": startedAt,
|
||||
"finished_at": doneAt,
|
||||
"install_dir": s.ffmpegInstallDir(),
|
||||
}
|
||||
target, targetErr := ffmpegTargetForPlatform()
|
||||
if targetErr != nil {
|
||||
out["target"] = map[string]any{"label": targetErr.Error()}
|
||||
} else {
|
||||
out["target"] = map[string]any{
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"label": target.Label,
|
||||
}
|
||||
}
|
||||
// 报告「系统当前实际会使用」的工具:优先已生效配置(安装完成会把设置指到
|
||||
// data 目录),其次 PATH / 常见目录。
|
||||
ffmpegPath, ferr := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
ffprobePath, perr := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
out["ffmpeg"] = ffToolInfo{Installed: ferr == nil, Path: ffmpegPath, Version: ffToolVersion(ffmpegPath)}
|
||||
out["ffprobe"] = ffToolInfo{Installed: perr == nil, Path: ffprobePath, Version: ffToolVersion(ffprobePath)}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartInstall 启动后台安装(幂等)。正在安装时返回错误;data 目录已有完整
|
||||
// 工具时直接应用路径设置并返回(无需重新下载)。
|
||||
func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return errors.New("工具正在安装中,请稍候")
|
||||
}
|
||||
if ffmpeg, ffprobe := s.installedBinaries(); ffmpeg != "" && ffprobe != "" {
|
||||
s.mu.Unlock()
|
||||
s.setMessage("检测到已安装,直接应用配置")
|
||||
if err := s.applyInstalledPaths(ctx, ffmpeg, ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
s.running = true
|
||||
s.errMsg = ""
|
||||
s.started = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
s.setMessage("准备下载…")
|
||||
go s.runInstall()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runInstall 在后台执行下载、解压、验证与配置落盘。
|
||||
func (s *FFmpegToolsService) runInstall() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.done = time.Now()
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ffmpegPath, ffprobePath, err := installFFmpegTools(ctx, s.log, s.cfg, s.setMessage)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = err.Error()
|
||||
s.msg = "安装失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("ffmpeg 工具安装失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.applyInstalledPaths(ctx, ffmpegPath, ffprobePath); err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = "安装完成,但写入设置失败:" + err.Error()
|
||||
s.msg = "安装完成,设置写入失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("写入 ffmpeg 工具路径设置失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.setMessage("安装完成")
|
||||
s.log.Info("ffmpeg 工具安装完成",
|
||||
zap.String("ffmpeg", ffmpegPath), zap.String("ffprobe", ffprobePath))
|
||||
}
|
||||
|
||||
// applyInstalledPaths 把安装后的路径写入设置表并热应用到运行配置。
|
||||
func (s *FFmpegToolsService) applyInstalledPaths(ctx context.Context, ffmpeg, ffprobe string) error {
|
||||
if s.repo != nil && s.repo.Setting != nil {
|
||||
if err := s.repo.Setting.Set(ctx, "ffmpeg.path", ffmpeg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Setting.Set(ctx, "ffprobe.path", ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ApplyRuntimeSetting(s.cfg, "ffmpeg.path", ffmpeg)
|
||||
ApplyRuntimeSetting(s.cfg, "ffprobe.path", ffprobe)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FFmpegToolsService) setMessage(msg string) {
|
||||
s.mu.Lock()
|
||||
s.msg = msg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func TestFFmpegTargetForPlatform(t *testing.T) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
switch runtime.GOOS {
|
||||
case "windows", "linux", "darwin":
|
||||
if err != nil {
|
||||
t.Fatalf("supported platform %s/%s should resolve a target: %v", runtime.GOOS, runtime.GOARCH, err)
|
||||
}
|
||||
if target == nil || target.Label == "" || len(target.Archives) == 0 {
|
||||
t.Fatalf("target incomplete: %#v", target)
|
||||
}
|
||||
if target.Kind != "zip" && target.Kind != "tar.xz" {
|
||||
t.Fatalf("unexpected archive kind: %s", target.Kind)
|
||||
}
|
||||
for _, u := range target.Archives {
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Fatalf("archive url not https: %s", u)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if err == nil {
|
||||
t.Fatalf("unsupported platform %s/%s should fail", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeZipTargetRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"../evil", "..\\evil", "/etc/passwd", "a/../../evil"} {
|
||||
if _, err := safeZipTarget(root, name); err == nil {
|
||||
t.Fatalf("expected traversal rejection for %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := safeZipTarget(root, "bin/ffmpeg.exe"); err != nil {
|
||||
t.Fatalf("valid relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
|
||||
st := svc.Status(context.Background())
|
||||
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
|
||||
if _, ok := st[key]; !ok {
|
||||
t.Fatalf("status missing key %q: %#v", key, st)
|
||||
}
|
||||
}
|
||||
ffmpeg, ok := st["ffmpeg"].(ffToolInfo)
|
||||
if !ok {
|
||||
t.Fatalf("ffmpeg field not ffToolInfo: %T", st["ffmpeg"])
|
||||
}
|
||||
if ffmpeg.Installed {
|
||||
t.Fatalf("empty config should not report installed ffmpeg: %#v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{App: config.AppConfig{DataDir: t.TempDir()}}, zap.NewNop(), nil)
|
||||
// 不真实运行:直接占用 running 标记模拟进行中的安装。
|
||||
svc.mu.Lock()
|
||||
svc.running = true
|
||||
svc.mu.Unlock()
|
||||
if err := svc.StartInstall(context.Background()); err == nil {
|
||||
t.Fatalf("second install while running should be rejected")
|
||||
}
|
||||
svc.mu.Lock()
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
if doc, _, err := decodeNFOFile(path); err == nil && doc != nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
@@ -62,7 +62,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
for _, match := range matches {
|
||||
baseKey := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(filepath.Base(match), filepath.Ext(match)), "-", ""))
|
||||
if strings.Contains(baseKey, codeKey) || strings.Contains(codeKey, baseKey) {
|
||||
if doc, _, err := readNFO(match); err == nil {
|
||||
if doc, _, err := decodeNFOFile(match); err == nil && doc != nil {
|
||||
return doc, match, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
@@ -71,7 +71,7 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
if doc, _, err := readNFO(matches[0]); err == nil {
|
||||
if doc, _, err := decodeNFOFile(matches[0]); err == nil && doc != nil {
|
||||
return doc, matches[0], nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
@@ -84,21 +84,33 @@ func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
var meta *LocalMetadata
|
||||
showBaseDir := ""
|
||||
if showDoc, showPath, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil {
|
||||
if showDoc, showPath, partial, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil {
|
||||
showBaseDir = filepath.Dir(showPath)
|
||||
meta = metadataFromDoc(showDoc, showBaseDir, true)
|
||||
// A truncated show NFO still yields a usable title/poster (decodePartialNFO
|
||||
// only surfaces docs with recoverable fields). Keep HasNFO so the recovered
|
||||
// title participates in series grouping; a fully partial episode match
|
||||
// below can still demote it.
|
||||
meta.HasNFO = !partial || meta.HasNFO
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
// A damaged show NFO must not discard the whole series: readLocalScanMetadata
|
||||
// treats this as a hard failure and leaves every episode without local
|
||||
// metadata. When the show level fails we still merge the episode NFO and
|
||||
// local artwork below instead of returning early.
|
||||
meta = nil
|
||||
}
|
||||
|
||||
if episodeDoc, episodePath, err := readNFO(nfoPath(mediaPath)); err == nil {
|
||||
if episodeDoc, episodePath, episodePartial, err := readEpisodeNFO(nfoPath(mediaPath)); err == nil && episodeDoc != nil {
|
||||
episodeMeta := metadataFromDoc(episodeDoc, filepath.Dir(episodePath), true)
|
||||
if meta == nil {
|
||||
meta = &LocalMetadata{}
|
||||
}
|
||||
mergeEpisodeMetadata(meta, episodeMeta, episodeDoc)
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
// Only a fully valid episode NFO can confirm the match; a truncated one
|
||||
// keeps whatever the show NFO supplied but must not force scrape_status.
|
||||
if episodePartial {
|
||||
meta.HasNFO = false
|
||||
}
|
||||
}
|
||||
if meta == nil {
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
||||
@@ -108,19 +120,49 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readNFO(path string) (*nfoDocument, string, error) {
|
||||
// readEpisodeNFO reads the sidecar NFO next to an episode file and reports both
|
||||
// the recovered document and whether it came from a truncated/malformed file.
|
||||
func readEpisodeNFO(path string) (*nfoDocument, string, bool, error) {
|
||||
body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root.
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", false, err
|
||||
}
|
||||
var doc nfoDocument
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return nil, "", err
|
||||
doc, partial, err := decodePartialNFO(body)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return &doc, path, nil
|
||||
return doc, path, partial, nil
|
||||
}
|
||||
|
||||
func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
func decodePartialNFO(body []byte) (*nfoDocument, bool, error) {
|
||||
var doc nfoDocument
|
||||
err := xml.Unmarshal(body, &doc)
|
||||
if err == nil {
|
||||
return &doc, false, nil
|
||||
}
|
||||
if !isLikelyTruncatedXMLError(err) {
|
||||
// A real parse error unrelated to truncation: don't trust partial fields.
|
||||
return nil, false, err
|
||||
}
|
||||
// Unmarshal still fills the elements it closed before hitting the cut; treat
|
||||
// those as partial show metadata instead of dropping everything.
|
||||
if doc.Title == "" && doc.OriginalTitle == "" && len(doc.Thumbs) == 0 &&
|
||||
doc.Premiered == "" && doc.Plot == "" {
|
||||
return nil, false, err
|
||||
}
|
||||
return &doc, true, nil
|
||||
}
|
||||
|
||||
func isLikelyTruncatedXMLError(err error) bool {
|
||||
for _, msg := range []string{"unexpected EOF", "EOF"} {
|
||||
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(msg)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, bool, error) {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
root := filepath.Clean(libraryRoot)
|
||||
for {
|
||||
@@ -133,19 +175,33 @@ func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
names = append(names, base+".nfo")
|
||||
for _, name := range names {
|
||||
path := filepath.Join(dir, name)
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
doc, partial, err := decodeNFOFile(path)
|
||||
if doc != nil {
|
||||
return doc, path, partial, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", false, err
|
||||
}
|
||||
}
|
||||
if samePath(dir, root) {
|
||||
return nil, "", os.ErrNotExist
|
||||
return nil, "", false, os.ErrNotExist
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, "", os.ErrNotExist
|
||||
return nil, "", false, os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func decodeNFOFile(path string) (*nfoDocument, bool, error) {
|
||||
body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root.
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
doc, partial, err := decodePartialNFO(body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return doc, partial, nil
|
||||
}
|
||||
|
||||
@@ -267,3 +267,111 @@ func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
|
||||
t.Fatalf("unexpected artwork metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadLocalMetadataRecoversFromTruncatedShowNFO mirrors a real-world issue:
|
||||
// some anime tvshow.nfo files are truncated mid-URL (unexpected EOF). Before the
|
||||
// fix this discarded the whole series, leaving episodes pending with per-episode
|
||||
// titles and no poster. The recoverable fields (title/year) and the matching
|
||||
// episode NFO + local artwork must still be applied.
|
||||
func TestReadLocalMetadataRecoversFromTruncatedShowNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "夏日重现 (2022)")
|
||||
seasonDir := filepath.Join(showDir, "Season 1")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "S01E01.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// tvshow.nfo cut off inside a <thumb> URL, like the broken real files.
|
||||
truncated := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<tvshow>
|
||||
<title>夏日重现</title>
|
||||
<originaltitle>サマータイムレンダ</originaltitle>
|
||||
<year>2022</year>
|
||||
<plot>听闻自己青梅竹马死讯。</plot>
|
||||
<thumb aspect="poster">https://image.tmdb.org/t/p/original/2koyWLm6iVn5OTEExTjKzVms5Iz.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>https://image.tmdb.org/t/p/original/p2eZlGwd8OjkWpwD2hSoBiIlHBZ.jpg</thu`
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(truncated), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The episode sidecar NFO is complete and should still be merged.
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>再见了夏日</title><season>1</season><episode>1</episode></episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Local artwork next to the show folder.
|
||||
poster := filepath.Join(showDir, "poster.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLocalMetadata returned error on truncated show NFO: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil; truncated show NFO discarded the series")
|
||||
}
|
||||
// The recovered show title takes precedence over the episode title.
|
||||
if got.Title != "夏日重现" {
|
||||
t.Fatalf("Title = %q, want recovered show title 夏日重现", got.Title)
|
||||
}
|
||||
if got.Year != 2022 {
|
||||
t.Fatalf("Year = %d, want 2022", got.Year)
|
||||
}
|
||||
if got.EpisodeTitle != "再见了夏日" || got.SeasonNum != 1 || got.EpisodeNum != 1 {
|
||||
t.Fatalf("episode metadata not preserved: %+v", got)
|
||||
}
|
||||
// Prior to the fix the episode metadata was dropped entirely; the poster comes
|
||||
// from the local poster.jpg next to the show.
|
||||
if got.PosterURL != poster {
|
||||
t.Fatalf("PosterURL = %q, want local poster %q", got.PosterURL, poster)
|
||||
}
|
||||
// A truncated show NFO must not be treated as an authoritative match by
|
||||
// itself; since the episode NFO is valid we still mark it matched so the
|
||||
// recovered series title participates in grouping.
|
||||
if !got.HasNFO {
|
||||
t.Fatalf("HasNFO = false, want true (episode NFO is valid)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadLocalMetadataKeepsArtworkOnlyWhenNoUsableNFO verifies that when a show
|
||||
// NFO is truncated AND yields no recoverable fields, we still fall back to local
|
||||
// artwork instead of returning an error.
|
||||
func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "Some Show")
|
||||
seasonDir := filepath.Join(showDir, "Season 1")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "S01E01.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Severely truncated: no recoverable title/fields at all.
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>半截`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Episode-level backdrop sits next to the episode file.
|
||||
backdrop := filepath.Join(seasonDir, "S01E01-backdrop.jpg")
|
||||
if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLocalMetadata should not error when NFO is unusable: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil; artwork fallback missing")
|
||||
}
|
||||
if got.HasNFO {
|
||||
t.Fatalf("HasNFO = true, want false for unusable NFO")
|
||||
}
|
||||
if got.BackdropURL != backdrop {
|
||||
t.Fatalf("expected episode artwork fallback, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,18 +32,21 @@ func (s *ScraperService) manualTMDbCandidates(ctx context.Context, query string,
|
||||
for _, typ := range manualTMDbSearchTypes(mediaType) {
|
||||
switch typ {
|
||||
case "movie":
|
||||
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil {
|
||||
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil && len(matches) > 0 {
|
||||
for _, match := range matches {
|
||||
out = append(out, manualTMDbCandidate{MediaType: "movie", Match: match})
|
||||
}
|
||||
}
|
||||
case "tv":
|
||||
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil {
|
||||
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil && len(matches) > 0 {
|
||||
for _, match := range matches {
|
||||
out = append(out, manualTMDbCandidate{MediaType: "tv", Match: match})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -70,15 +73,15 @@ func manualTMDbIDSearchTypes(mediaType string) []string {
|
||||
|
||||
func manualTMDbSearchTypes(mediaType string) []string {
|
||||
if strings.TrimSpace(mediaType) == "" {
|
||||
return []string{"movie", "tv"}
|
||||
return []string{"tv", "movie"}
|
||||
}
|
||||
switch normalizeMediaType(mediaType, "", "") {
|
||||
case "tv", "anime", "variety":
|
||||
return []string{"tv", "movie"}
|
||||
case "movie", "adult":
|
||||
return []string{"movie"}
|
||||
default:
|
||||
return []string{"movie", "tv"}
|
||||
default:
|
||||
return []string{"tv", "movie"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
|
||||
if len(results) != 1 || results[0].TMDbID != 27205 {
|
||||
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
||||
|
||||
@@ -4,14 +4,88 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LibraryPreviewItem struct {
|
||||
model.Library
|
||||
Total int64 `json:"total"`
|
||||
Cards []SeriesCard `json:"cards"`
|
||||
}
|
||||
|
||||
// ListLibraries returns every library configured on the server.
|
||||
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
||||
return s.repo.Library.List(ctx)
|
||||
}
|
||||
|
||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
||||
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
if cardLimit <= 0 {
|
||||
cardLimit = 10
|
||||
}
|
||||
out := make([]LibraryPreviewItem, len(libraries))
|
||||
if len(libraries) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
libIDs := make([]string, 0, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
out[i] = LibraryPreviewItem{
|
||||
Library: lib,
|
||||
Total: 0,
|
||||
Cards: []SeriesCard{},
|
||||
}
|
||||
libIDs = append(libIDs, lib.ID)
|
||||
}
|
||||
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
filter := repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if total, ok := counts[out[i].ID]; ok {
|
||||
out[i].Total = total
|
||||
}
|
||||
}
|
||||
|
||||
fetchCount := cardLimit * 4
|
||||
if fetchCount < 60 {
|
||||
fetchCount = 60
|
||||
} else if fetchCount > 200 {
|
||||
fetchCount = 200
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if out[i].Total == 0 {
|
||||
continue
|
||||
}
|
||||
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
||||
// left untouched.
|
||||
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -51,7 +52,14 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
|
||||
s.invalidateMediaCache(ctx)
|
||||
return lib, nil
|
||||
}
|
||||
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true}
|
||||
lib := &model.Library{
|
||||
Name: strings.TrimSpace(name),
|
||||
Path: roots[0].Path,
|
||||
Type: kind,
|
||||
CoverURL: strings.TrimSpace(coverURL),
|
||||
Enabled: true,
|
||||
CarouselEnabled: false,
|
||||
}
|
||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,11 +67,72 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
|
||||
return lib, nil
|
||||
}
|
||||
|
||||
// CreateLibrariesPerSubfolder 为 parent 目录下的每个直接子目录各建一个媒体库,
|
||||
// 媒体库名取子目录名,路径指向该子目录。kind 为空时按子目录名推断类型。
|
||||
func (s *MediaService) CreateLibrariesPerSubfolder(ctx context.Context, parent, kind, coverURL string) ([]model.Library, error) {
|
||||
parent = strings.TrimSpace(parent)
|
||||
if parent == "" {
|
||||
return nil, errors.New("parent path required")
|
||||
}
|
||||
dir, err := resolveAccessibleLibraryPath(parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read directory failed: %w", err)
|
||||
}
|
||||
subdirs := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
subdirs = append(subdirs, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
if len(subdirs) == 0 {
|
||||
return nil, errors.New("no subfolders found")
|
||||
}
|
||||
created := make([]model.Library, 0, len(subdirs))
|
||||
for _, subdir := range subdirs {
|
||||
name := filepath.Base(subdir)
|
||||
lib, err := s.CreateLibraryWithRootsAndCover(ctx, name, kind, coverURL, []LibraryRootInput{{Path: subdir}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create library for %s: %w", subdir, err)
|
||||
}
|
||||
created = append(created, *lib)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error {
|
||||
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).
|
||||
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
||||
}
|
||||
|
||||
// UpdateLibraryFields updates sort_order / carousel_enabled on a library.
|
||||
func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error {
|
||||
updates := map[string]any{}
|
||||
if sortOrder != nil {
|
||||
updates["sort_order"] = *sortOrder
|
||||
}
|
||||
if carouselEnabled != nil {
|
||||
updates["carousel_enabled"] = *carouselEnabled
|
||||
}
|
||||
if len(updates) == 0 || strings.TrimSpace(libraryID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).
|
||||
Where("id = ?", libraryID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// ReorderLibraries persists a full media-library ordering.
|
||||
func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error {
|
||||
return s.repo.Library.SetSortOrder(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Library == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
func TestListLibrariesWithPreview(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
|
||||
lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
var rows []model.Media
|
||||
|
||||
// Add 5 movies to lib1
|
||||
for i := 1; i <= 5; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib1.ID,
|
||||
Title: fmt.Sprintf("电影%d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i),
|
||||
PosterURL: fmt.Sprintf("/api/media/movie-%02d/poster", i),
|
||||
})
|
||||
}
|
||||
|
||||
// Add 12 episodes of 1 anime to lib2
|
||||
for i := 1; i <= 12; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib2.ID,
|
||||
Title: fmt.Sprintf("某动漫 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
PosterURL: "/api/media/anime-01/poster",
|
||||
})
|
||||
}
|
||||
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
|
||||
previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib1, lib2}, MediaVisibility{IncludeNSFW: true}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLibrariesWithPreview failed: %v", err)
|
||||
}
|
||||
|
||||
if len(previews) != 2 {
|
||||
t.Fatalf("got %d previews, want 2", len(previews))
|
||||
}
|
||||
|
||||
// Verify lib1 (movies)
|
||||
if previews[0].ID != lib1.ID {
|
||||
t.Errorf("preview[0].ID = %q, want %q", previews[0].ID, lib1.ID)
|
||||
}
|
||||
if previews[0].Total != 5 {
|
||||
t.Errorf("preview[0].Total = %d, want 5", previews[0].Total)
|
||||
}
|
||||
if len(previews[0].Cards) != 5 {
|
||||
t.Errorf("preview[0].Cards count = %d, want 5", len(previews[0].Cards))
|
||||
}
|
||||
|
||||
// Verify lib2 (anime)
|
||||
if previews[1].ID != lib2.ID {
|
||||
t.Errorf("preview[1].ID = %q, want %q", previews[1].ID, lib2.ID)
|
||||
}
|
||||
if previews[1].Total != 12 {
|
||||
t.Errorf("preview[1].Total = %d, want 12", previews[1].Total)
|
||||
}
|
||||
// 12 episodes should be grouped into 1 SeriesCard with Count = 12
|
||||
if len(previews[1].Cards) != 1 {
|
||||
t.Errorf("preview[1].Cards count = %d, want 1", len(previews[1].Cards))
|
||||
} else if previews[1].Cards[0].Count != 12 {
|
||||
t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -75,6 +77,74 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
target, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if !visibility.Allows(target) {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if target.LibraryID == "" {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
rows, _, err := s.listAllMediaVisible(ctx, target.LibraryID, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
|
||||
resolver := newMediaSeriesKeyResolver(rows)
|
||||
targetKey := resolver.key(*target)
|
||||
|
||||
out := make([]model.Media, 0)
|
||||
if targetKey != "" {
|
||||
for _, row := range rows {
|
||||
if resolver.key(row) == targetKey {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SeasonNum != out[j].SeasonNum {
|
||||
return out[i].SeasonNum < out[j].SeasonNum
|
||||
}
|
||||
if out[i].EpisodeNum != out[j].EpisodeNum {
|
||||
return out[i].EpisodeNum < out[j].EpisodeNum
|
||||
}
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||
const pageSize = 2000
|
||||
var all []model.Media
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
type ScrapeQueueCounts struct {
|
||||
Pending int64 `json:"pending"`
|
||||
Running int64 `json:"running"`
|
||||
Done int64 `json:"done"`
|
||||
Failed int64 `json:"failed"`
|
||||
Canceled int64 `json:"canceled"`
|
||||
}
|
||||
|
||||
type ScrapeQueueSnapshot struct {
|
||||
Counts ScrapeQueueCounts `json:"counts"`
|
||||
Tasks []model.ScrapeTask `json:"tasks"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Start 启动刮削任务队列的后台消费者。
|
||||
func (s *ScraperService) Start(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
go s.queueWorker(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) queueWorker(ctx context.Context) {
|
||||
const claimBatch = 4
|
||||
sem := make(chan struct{}, 2) // 最大并发刮削数:2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
tasks, err := s.repo.ScrapeTask.ClaimPending(ctx, claimBatch)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("claim pending scrape task failed", zap.Error(err))
|
||||
}
|
||||
sleepContext(ctx, 3*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
sleepContext(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range tasks {
|
||||
wg.Add(1)
|
||||
go func(t *model.ScrapeTask) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.processScrapeTask(ctx, t)
|
||||
}(&tasks[i])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.ScrapeTask) {
|
||||
media, err := s.repo.Media.FindByID(ctx, task.MediaID)
|
||||
if err != nil || media == nil {
|
||||
now := time.Now()
|
||||
task.Status = model.ScrapeTaskFailed
|
||||
task.Error = "媒体项已不存在或被删除"
|
||||
task.FinishedAt = &now
|
||||
_ = s.repo.ScrapeTask.Update(ctx, task)
|
||||
return
|
||||
}
|
||||
|
||||
epArtwork := task.EpisodeImages
|
||||
options := ScrapeOptions{
|
||||
EpisodeArtwork: &epArtwork,
|
||||
IncludeMatched: task.RefreshMatched,
|
||||
RetryNoMatch: true,
|
||||
}
|
||||
|
||||
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
|
||||
now := time.Now()
|
||||
task.FinishedAt = &now
|
||||
|
||||
refreshed, _ := s.repo.Media.FindByID(ctx, media.ID)
|
||||
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
|
||||
task.Status = model.ScrapeTaskDone
|
||||
task.Error = ""
|
||||
task.MatchedTitle = refreshed.Title
|
||||
task.MatchedYear = refreshed.Year
|
||||
task.PosterURL = refreshed.PosterURL
|
||||
task.BackdropURL = refreshed.BackdropURL
|
||||
if refreshed.TMDbID > 0 {
|
||||
task.Provider = "tmdb"
|
||||
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
|
||||
task.Provider = "douban"
|
||||
} else if refreshed.BangumiID > 0 {
|
||||
task.Provider = "bangumi"
|
||||
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
|
||||
task.Provider = "thetvdb"
|
||||
} else {
|
||||
task.Provider = "metatube"
|
||||
}
|
||||
} else {
|
||||
task.Status = model.ScrapeTaskFailed
|
||||
if enrichErr != nil {
|
||||
task.Error = enrichErr.Error()
|
||||
} else if refreshed != nil && refreshed.ScrapeStatus == "no_match" {
|
||||
task.Error = "未搜索到匹配的元数据"
|
||||
} else {
|
||||
task.Error = "刮削未完成匹配"
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.repo.ScrapeTask.Update(ctx, task)
|
||||
|
||||
if s.hub != nil {
|
||||
s.hub.Publish("scraper_queue", map[string]any{
|
||||
"task_id": task.ID,
|
||||
"status": task.Status,
|
||||
"title": task.MediaTitle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) mediaKind(m *model.Media, lib *model.Library) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if lib != nil && lib.Type != "" {
|
||||
return lib.Type
|
||||
}
|
||||
if mediaIsEpisodic(m, lib) {
|
||||
return "tv"
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
// EnqueueMedia 把单个媒体项放入刮削队列。
|
||||
func (s *ScraperService) EnqueueMedia(ctx context.Context, mediaID string, options ScrapeOptions) (*model.ScrapeTask, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, errors.New("scraper service not initialized")
|
||||
}
|
||||
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || media == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
|
||||
if active, _ := s.repo.ScrapeTask.FindActiveByMediaID(ctx, mediaID); active != nil {
|
||||
return active, nil
|
||||
}
|
||||
|
||||
libName := ""
|
||||
var lib *model.Library
|
||||
if strings.TrimSpace(media.LibraryID) != "" {
|
||||
lib, _ = s.repo.Library.FindByID(ctx, media.LibraryID)
|
||||
if lib != nil {
|
||||
libName = lib.Name
|
||||
}
|
||||
}
|
||||
|
||||
task := &model.ScrapeTask{
|
||||
MediaID: media.ID,
|
||||
LibraryID: media.LibraryID,
|
||||
LibraryName: libName,
|
||||
MediaTitle: media.Title,
|
||||
MediaPath: media.Path,
|
||||
MediaType: s.mediaKind(media, lib),
|
||||
Status: model.ScrapeTaskPending,
|
||||
EpisodeImages: options.episodeArtworkEnabled(),
|
||||
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
|
||||
}
|
||||
|
||||
if err := s.repo.ScrapeTask.Create(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// EnqueueLibrary 把指定媒体库内的所有候选媒体批量推入刮削队列。
|
||||
func (s *ScraperService) EnqueueLibrary(ctx context.Context, libraryID string, options ScrapeOptions) (int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, errors.New("scraper service not initialized")
|
||||
}
|
||||
|
||||
lib, err := s.repo.Library.FindByID(ctx, libraryID)
|
||||
if err != nil || lib == nil {
|
||||
return 0, errors.New("library not found")
|
||||
}
|
||||
|
||||
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tasks := make([]model.ScrapeTask, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
tasks = append(tasks, model.ScrapeTask{
|
||||
MediaID: m.ID,
|
||||
LibraryID: lib.ID,
|
||||
LibraryName: lib.Name,
|
||||
MediaTitle: m.Title,
|
||||
MediaPath: m.Path,
|
||||
MediaType: s.mediaKind(&m, lib),
|
||||
Status: model.ScrapeTaskPending,
|
||||
EpisodeImages: options.episodeArtworkEnabled(),
|
||||
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.ScrapeTask.CreateBatch(ctx, tasks); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(tasks), nil
|
||||
}
|
||||
|
||||
// EnqueueAll 把所有已启用媒体库的媒体推入刮削队列。
|
||||
func (s *ScraperService) EnqueueAll(ctx context.Context, options ScrapeOptions) (int, error) {
|
||||
libs, err := s.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for _, lib := range libs {
|
||||
if !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
n, err := s.EnqueueLibrary(ctx, lib.ID, options)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("enqueue library for scrape failed", zap.String("library", lib.ID), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) ScrapeQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*ScrapeQueueSnapshot, error) {
|
||||
tasks, total, err := s.repo.ScrapeTask.List(ctx, status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countsMap, err := s.repo.ScrapeTask.CountByStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := &ScrapeQueueSnapshot{
|
||||
Counts: ScrapeQueueCounts{
|
||||
Pending: countsMap[model.ScrapeTaskPending],
|
||||
Running: countsMap[model.ScrapeTaskRunning],
|
||||
Done: countsMap[model.ScrapeTaskDone],
|
||||
Failed: countsMap[model.ScrapeTaskFailed],
|
||||
Canceled: countsMap[model.ScrapeTaskCanceled],
|
||||
},
|
||||
Tasks: tasks,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) CancelScrapeTask(ctx context.Context, id string) error {
|
||||
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
|
||||
if err != nil || task == nil {
|
||||
return errors.New("刮削任务不存在")
|
||||
}
|
||||
if task.Status != model.ScrapeTaskPending && task.Status != model.ScrapeTaskRunning {
|
||||
return errors.New("任务已完成或已终止,无法取消")
|
||||
}
|
||||
now := time.Now()
|
||||
task.Status = model.ScrapeTaskCanceled
|
||||
task.Error = "已取消"
|
||||
task.FinishedAt = &now
|
||||
return s.repo.ScrapeTask.Update(ctx, task)
|
||||
}
|
||||
|
||||
func (s *ScraperService) RetryScrapeTask(ctx context.Context, id string) error {
|
||||
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
|
||||
if err != nil || task == nil {
|
||||
return errors.New("刮削任务不存在")
|
||||
}
|
||||
if task.Status != model.ScrapeTaskFailed && task.Status != model.ScrapeTaskCanceled {
|
||||
return errors.New("只有失败或已取消的任务可以重试")
|
||||
}
|
||||
task.Status = model.ScrapeTaskPending
|
||||
task.Error = ""
|
||||
task.RetryCount = 0
|
||||
task.StartedAt = nil
|
||||
task.FinishedAt = nil
|
||||
return s.repo.ScrapeTask.Update(ctx, task)
|
||||
}
|
||||
|
||||
func (s *ScraperService) DeleteScrapeTask(ctx context.Context, id string) error {
|
||||
return s.repo.ScrapeTask.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ScraperService) BatchActionScrapeTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.ScrapeTask.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.ScrapeTask.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.ScrapeTask.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearDoneScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearDone(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearFinishedScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearFinished(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearCanceledScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearCanceled(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) RetryAllFailedScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.RetryAllFailed(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) CancelPendingScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.CancelPending(ctx)
|
||||
}
|
||||
@@ -58,11 +58,13 @@ type Container struct {
|
||||
Device *DeviceService
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
|
||||
@@ -102,7 +104,12 @@ func (c *Container) Boot() {
|
||||
c.Strm.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 启动刮削队列后台消费者
|
||||
if c.Scraper != nil {
|
||||
c.Scraper.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
|
||||
@@ -111,6 +111,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
}
|
||||
@@ -118,6 +120,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
|
||||
b.c.Permissions = NewPermissionService(b.log, b.repos)
|
||||
b.c.Database = NewDatabaseAdminService(b.cfg, b.log, b.repos, b.repos.DB)
|
||||
b.c.Emby.SetRuntimeCache(b.c.Cache)
|
||||
b.c.Emby.SetSubtitleService(b.c.Subtitle)
|
||||
b.c.Scheduler = NewSchedulerService(
|
||||
|
||||
@@ -116,6 +116,64 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
||||
return nil, errors.New("文件不在任何本地同步目录内")
|
||||
}
|
||||
|
||||
// ResolvePlayTarget 解析媒体行固化的播放目标(STRMURL 或 .strm 文件内容)为
|
||||
// 可播放结果,供弹幕 hash、内嵌字幕提取等「先解析直链再读取远端」的场景复用。
|
||||
// 支持:
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||
// - 绝对 http(s) 链接(直接透传)
|
||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.New("空播放目标")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||
segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
|
||||
if len(segs) < 1 || strings.TrimSpace(segs[0]) == "" {
|
||||
return nil, errors.New("无效的 strm 播放地址")
|
||||
}
|
||||
return s.ResolvePlay(ctx, segs[0], u.Query())
|
||||
case strings.HasPrefix(lowerPath, "/api/cloud/play/"):
|
||||
typ := strings.TrimSpace(strings.TrimPrefix(u.Path, "/api/cloud/play/"))
|
||||
acct, err := s.firstEnabledAccountOf(ctx, typ)
|
||||
if err != nil || acct == nil {
|
||||
return nil, errors.New("没有可用的网盘账号,无法解析直链")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("acct", acct.ID)
|
||||
return s.ResolvePlay(ctx, typ, q)
|
||||
case u.Scheme == "http" || u.Scheme == "https":
|
||||
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range accounts {
|
||||
a := &accounts[i]
|
||||
if !a.Enabled || a.Provider != provider {
|
||||
continue
|
||||
}
|
||||
if _, err := s.providerFor(ctx, a); err == nil {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
if link == nil || link.URL == "" {
|
||||
|
||||
@@ -28,8 +28,10 @@ const (
|
||||
)
|
||||
|
||||
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
|
||||
// 每次批量认领数个任务,并对这批任务并发下载,让「换直链」和「实际下载」
|
||||
// 在不同任务间重叠,从而充分利用多线程与 115 换链 QPS。
|
||||
//
|
||||
// 采用「批量认领 + 全局并发限流」:一次认领数个任务,用 StrmService 上的全局信号量
|
||||
// 限制整个进程「同时换直链+下载」的并发数(与 115 换链风控匹配,见 strmDownloadSemCap),
|
||||
// 同时让下载充分并行。换链走全局令牌桶(QPS=3)兜底,下载走 CDN 不限速。
|
||||
func (s *StrmService) downloadWorker(ctx context.Context) {
|
||||
const claimBatch = 12 // 每次批量认领的任务数
|
||||
for {
|
||||
@@ -56,12 +58,17 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
|
||||
sleepContext(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
// 并发处理本批认领到的任务,充分利用多线程下载 & 直链换取并发
|
||||
// 并发处理本批任务:每个任务先获取全局下载槽位,槽位内部执行换链+下载。
|
||||
// 信号量与令牌桶双重限速,确保任意时刻并发换链请求不超过安全阈值。
|
||||
var wg sync.WaitGroup
|
||||
for i := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if !s.acquireDownloadSlot(ctx) {
|
||||
return
|
||||
}
|
||||
defer s.releaseDownloadSlot()
|
||||
s.processDownloadTask(ctx, &tasks[i])
|
||||
}(i)
|
||||
}
|
||||
@@ -150,7 +157,7 @@ func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUpl
|
||||
}
|
||||
}
|
||||
if task.Provider == model.StrmProvider115 {
|
||||
finish(model.StrmTaskFailed, "115 网盘暂不支持元数据上传")
|
||||
s.processUpload115(ctx, task)
|
||||
return
|
||||
}
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
|
||||
@@ -189,6 +196,48 @@ func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUpl
|
||||
finish(model.StrmTaskDone, "")
|
||||
}
|
||||
|
||||
// processUpload115 115 元数据上传:task.RemotePath 存的是父目录 cid,FileName 为远端文件名。
|
||||
func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUploadTask) {
|
||||
finish := func(status, message string) {
|
||||
now := time.Now()
|
||||
task.Status = status
|
||||
task.Error = message
|
||||
task.FinishedAt = &now
|
||||
if err := s.repo.StrmUpload.Update(context.Background(), task); err != nil {
|
||||
s.log.Warn("update strm upload task failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
|
||||
if err != nil || acct == nil {
|
||||
finish(model.StrmTaskFailed, "网盘账号不存在")
|
||||
return
|
||||
}
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
s.uploadTaskFailWithRetry(task, err.Error())
|
||||
return
|
||||
}
|
||||
named, ok := provider.(interface {
|
||||
PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error
|
||||
})
|
||||
if !ok {
|
||||
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
|
||||
return
|
||||
}
|
||||
f, err := os.Open(task.LocalPath)
|
||||
if err != nil {
|
||||
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := named.PutFileNamed(ctx, task.RemotePath, task.FileName, f); err != nil {
|
||||
_ = f.Close()
|
||||
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
finish(model.StrmTaskDone, "")
|
||||
}
|
||||
|
||||
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
|
||||
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
|
||||
if !retryTask(&task.RetryCount, &task.Status, &task.Error, &task.NextTryAt, &task.FinishedAt, message) {
|
||||
@@ -502,6 +551,44 @@ func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
|
||||
|
||||
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
|
||||
|
||||
// DeleteDownloadTask 删除一个下载任务记录。
|
||||
func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error {
|
||||
return s.repo.StrmDownload.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteUploadTask 删除一个上传任务记录。
|
||||
func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error {
|
||||
return s.repo.StrmUpload.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。
|
||||
func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.StrmDownload.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.StrmDownload.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.StrmDownload.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的批量操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。
|
||||
func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.StrmUpload.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.StrmUpload.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.StrmUpload.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的批量操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
|
||||
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmDownload.ClearDone(ctx)
|
||||
|
||||
@@ -90,11 +90,48 @@ type StrmService struct {
|
||||
running map[string]context.CancelFunc // sync path id -> cancel
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
|
||||
downloadSemOnce sync.Once
|
||||
}
|
||||
|
||||
// strmWAFCooldown 检测到 115 风控/限流后下载队列的全局冷却时长。
|
||||
const strmWAFCooldown = 3 * time.Minute
|
||||
|
||||
// strmDownloadSemCap 全局同时进行「换直链+下载」的并发上限。
|
||||
//
|
||||
// 115 对换直链接口(/open/ufile/downurl)风控极严:过去把全局 QPS 提到 8 或让多
|
||||
// worker 高并发换链,会瞬时撞上 WAF 返回 405 阻断页并触发 180 秒冷却,反而更慢。
|
||||
// 因此用信号量把整个进程同时换直链的并发数压到 3,与令牌桶限速共同兜底:
|
||||
// 宁可下载稍慢,也绝不触发风控。下载本身走 CDN 不限速。
|
||||
const strmDownloadSemCap = 3
|
||||
|
||||
// ensureDownloadSem 惰性初始化全局共享的下载并发信号量。
|
||||
func (s *StrmService) ensureDownloadSem() {
|
||||
s.downloadSemOnce.Do(func() {
|
||||
s.downloadSem = make(chan struct{}, strmDownloadSemCap)
|
||||
})
|
||||
}
|
||||
|
||||
// acquireDownloadSlot 获取一个下载并发槽位(等待/取消安全)。
|
||||
func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
|
||||
s.ensureDownloadSem()
|
||||
select {
|
||||
case s.downloadSem <- struct{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// releaseDownloadSlot 释放一个下载并发槽位。
|
||||
func (s *StrmService) releaseDownloadSlot() {
|
||||
if s.downloadSem == nil {
|
||||
return
|
||||
}
|
||||
<-s.downloadSem
|
||||
}
|
||||
|
||||
// NewStrmService constructs the STRM service.
|
||||
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
|
||||
return &StrmService{
|
||||
@@ -412,6 +449,38 @@ func (s *StrmService) ListSyncRecords(ctx context.Context, pathID string, limit
|
||||
return s.repo.StrmSyncRecord.List(ctx, pathID, limit)
|
||||
}
|
||||
|
||||
// DeleteSyncRecord 删除单条同步记录。
|
||||
func (s *StrmService) DeleteSyncRecord(ctx context.Context, id string) error {
|
||||
if err := s.repo.StrmSyncRecord.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSyncRecords 清空某同步目录(pathID 为空则全部)的同步记录,返回删除条数。
|
||||
func (s *StrmService) ClearSyncRecords(ctx context.Context, pathID string) (int64, error) {
|
||||
if pathID != "" {
|
||||
return s.repo.StrmSyncRecord.DeleteBySyncPathID(ctx, pathID)
|
||||
}
|
||||
var total int64
|
||||
// 全量清空:分页拉取物理删除所有记录
|
||||
for {
|
||||
rows, err := s.repo.StrmSyncRecord.List(ctx, "", 200)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return total, nil
|
||||
}
|
||||
for _, rec := range rows {
|
||||
if err := s.repo.StrmSyncRecord.Delete(ctx, rec.ID); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
total += int64(len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSyncPath 校验并创建同步目录。
|
||||
func (s *StrmService) CreateSyncPath(ctx context.Context, p *model.StrmSyncPath) (*model.StrmSyncPath, error) {
|
||||
if err := s.validateSyncPath(ctx, p); err != nil {
|
||||
|
||||
+225
-128
@@ -46,7 +46,10 @@ type strmSyncState struct {
|
||||
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
||||
pendingDownloads []*model.StrmDownloadTask
|
||||
pendingUploads []*model.StrmUploadTask
|
||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
||||
|
||||
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
||||
}
|
||||
|
||||
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
|
||||
@@ -232,8 +235,8 @@ func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecor
|
||||
if rec.SyncType == model.StrmSyncTypeFull {
|
||||
syncTypeLabel = "全量"
|
||||
}
|
||||
p.LastSyncMessage = fmt.Sprintf("[%s] 完成:新增/更新 %d 个 strm,跳过 %d 个,下载 %d 个元数据,清理 %d 个文件",
|
||||
syncTypeLabel, rec.NewStrm, rec.Skipped, rec.NewMeta, rec.Pruned)
|
||||
p.LastSyncMessage = fmt.Sprintf("[%s] 完成:新增/更新 %d 个 strm,跳过 %d 个,下载 %d 个元数据,上传 %d 个元数据,清理 %d 个文件",
|
||||
syncTypeLabel, rec.NewStrm, rec.Skipped, rec.NewMeta, rec.Uploaded, rec.Pruned)
|
||||
}
|
||||
if err := s.repo.StrmSyncPath.Update(context.Background(), p); err != nil {
|
||||
s.log.Warn("update strm sync path failed", zap.Error(err))
|
||||
@@ -241,7 +244,7 @@ func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecor
|
||||
s.log.Info("strm sync finished",
|
||||
zap.String("path_id", p.ID), zap.String("sync_type", rec.SyncType), zap.String("status", status),
|
||||
zap.Int64("new_strm", rec.NewStrm), zap.Int64("skipped", rec.Skipped), zap.Int64("new_meta", rec.NewMeta),
|
||||
zap.Int64("pruned", rec.Pruned), zap.String("message", message))
|
||||
zap.Int64("uploaded", rec.Uploaded), zap.Int64("pruned", rec.Pruned), zap.String("message", message))
|
||||
}
|
||||
|
||||
func (st *strmSyncState) run() error {
|
||||
@@ -280,7 +283,21 @@ func (st *strmSyncState) run() error {
|
||||
}
|
||||
st.flushPendingDownloads()
|
||||
st.flushProgress()
|
||||
if st.cfg.UploadMeta && st.provider != nil && st.p.Provider != model.StrmProvider115 {
|
||||
if st.cfg.UploadMeta && st.provider != nil {
|
||||
// 115 上传需要父目录 cid,先用 dirCache 构建「路径 → cid」反向索引
|
||||
if st.p.Provider == model.StrmProvider115 {
|
||||
reversed := map[string]string{}
|
||||
st.dirCache.Range(func(key, value any) bool {
|
||||
path, ok := value.(string)
|
||||
if ok && path != "" {
|
||||
if id, ok2 := key.(string); ok2 {
|
||||
reversed[path] = id
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
st.dirPathToID = reversed
|
||||
}
|
||||
if err := st.scanLocalMetaForUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -439,11 +456,35 @@ func (st *strmSyncState) isMetaExt(ext string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanDirRel 对 115 扁平化拉取的目录相对路径逐段套用目录级文件名清洗,
|
||||
// 确保与 walkRemote / joinLocalRel(sanitizeRelativePath)使用同一套清洗规则。
|
||||
// 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/seenMeta 的 key
|
||||
// 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm / 元数据。
|
||||
// 空 rel(根目录)原样返回。
|
||||
func cleanDirRel(rel string) string {
|
||||
if rel == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
clean := cleanEntryName(part, true)
|
||||
if clean != "" && clean != "." && clean != ".." {
|
||||
out = append(out, clean)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// walk115Flat 使用 115 开放平台扁平化分页批量拉取机制与目录拓扑缓存(参考 QMediaSync)。
|
||||
// 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。
|
||||
func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
defer st.flushPendingDownloads()
|
||||
ctx := st.ctx
|
||||
ctx, cancel := context.WithCancel(st.ctx)
|
||||
defer cancel()
|
||||
rootCID := strings.TrimSpace(st.p.RemotePath)
|
||||
if rootCID == "" {
|
||||
rootCID = "0"
|
||||
@@ -456,23 +497,23 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
if err := st.s.repo.StrmDirCache.DeleteBySyncPathID(ctx, st.p.ID); err != nil {
|
||||
st.s.log.Warn("delete strm dir cache failed", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
// 增量同步:预加载历史目录缓存(过滤历史一对多塌陷冲突的脏数据以自愈刷新)
|
||||
cached, err := st.s.repo.StrmDirCache.ListBySyncPathID(ctx, st.p.ID)
|
||||
if err == nil {
|
||||
pathCounts := make(map[string]int, len(cached))
|
||||
for _, item := range cached {
|
||||
pathCounts[item.Path]++
|
||||
}
|
||||
for _, item := range cached {
|
||||
// 若同一个 path 对应了多个不同 dir_id,说明包含历史层级塌陷的脏数据,不预加载,让后续步骤重新向 115 获取精确路径
|
||||
if pathCounts[item.Path] > 1 {
|
||||
continue
|
||||
}
|
||||
st.dirCache.Store(item.DirID, item.Path)
|
||||
} else {
|
||||
// 增量同步:预加载历史目录缓存(过滤历史一对多塌陷冲突的脏数据以自愈刷新)
|
||||
cached, err := st.s.repo.StrmDirCache.ListBySyncPathID(ctx, st.p.ID)
|
||||
if err == nil {
|
||||
pathCounts := make(map[string]int, len(cached))
|
||||
for _, item := range cached {
|
||||
pathCounts[item.Path]++
|
||||
}
|
||||
for _, item := range cached {
|
||||
// 若同一个 path 对应了多个不同 dir_id,说明包含历史层级塌陷的脏数据,不预加载,让后续步骤重新向 115 获取精确路径
|
||||
if pathCounts[item.Path] > 1 {
|
||||
continue
|
||||
}
|
||||
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 探测文件总数
|
||||
const pageSize = 1150
|
||||
@@ -578,6 +619,8 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
dirWorkers = 8
|
||||
doneDirs atomic.Int64
|
||||
totalDirs = len(pidList)
|
||||
errMu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
if len(pidList) < dirWorkers {
|
||||
dirWorkers = len(pidList)
|
||||
@@ -601,10 +644,22 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
}
|
||||
detail, err := open115.GetFsDetailByCid(ctx, pid)
|
||||
if err != nil {
|
||||
st.s.log.Warn("115: 获取目录详情失败", zap.String("pid", pid), zap.Error(err))
|
||||
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
|
||||
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
|
||||
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
|
||||
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
|
||||
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
st.scanIncomplete.Store(true)
|
||||
cancel()
|
||||
return
|
||||
} else if detail != nil {
|
||||
// 解析相对路径
|
||||
relPath := detail.RelativePath(rootCID)
|
||||
relPath := cleanDirRel(detail.RelativePath(rootCID))
|
||||
st.dirCache.Store(pid, relPath)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
|
||||
|
||||
@@ -625,7 +680,7 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
ancestorRel := subDetail.RelativePath(rootCID)
|
||||
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
|
||||
st.dirCache.Store(ancestor.FileId, ancestorRel)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
|
||||
}
|
||||
@@ -638,6 +693,12 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
}()
|
||||
}
|
||||
pwg.Wait()
|
||||
if firstErr != nil {
|
||||
// 目录树解析失败会导致 rel 塌缩,若继续处理会让大量本地文件
|
||||
// 被错误判定为"云端不存在"而重复下载/上传,并可能误删本地文件。
|
||||
// 中止本次同步,避免在损坏的相对路径上执行任何写操作。
|
||||
return firstErr
|
||||
}
|
||||
}
|
||||
|
||||
st.updateSyncMessage(fmt.Sprintf("正在生成 STRM 与同步文件 (共 %d 个)...", len(allFiles)))
|
||||
@@ -653,9 +714,12 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
rel = cleanName
|
||||
} else {
|
||||
if parentVal, ok := st.dirCache.Load(f.Pid); ok && parentVal.(string) != "" {
|
||||
rel = parentVal.(string) + "/" + cleanName
|
||||
rel = cleanDirRel(parentVal.(string)) + "/" + cleanName
|
||||
} else {
|
||||
rel = cleanName
|
||||
// 父目录不在目录缓存,无法还原真实相对路径。若继续用塌缩后的
|
||||
// 根路径处理,该文件会被错误判定,导致重复下载/上传或误删本地文件。
|
||||
// 目录树不完整时宁可中止本次同步,也不带着损坏的 rel 继续执行。
|
||||
return fmt.Errorf("115: 文件 %s 的父目录未解析成功,目录树不完整,中止同步以防误删/误传", cleanName)
|
||||
}
|
||||
}
|
||||
entry := cloud.FileEntry{
|
||||
@@ -966,124 +1030,124 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mTime := info.ModTime()
|
||||
if st.syncType == model.StrmSyncTypeIncremental {
|
||||
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(target); err == nil && string(data) == content {
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mTime := info.ModTime()
|
||||
if st.syncType == model.StrmSyncTypeIncremental {
|
||||
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
|
||||
_ = os.Rename(tmp, target)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
} else {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(target); err == nil && string(data) == content {
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
|
||||
_ = os.Rename(tmp, target)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
} else {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
|
||||
func (st *strmSyncState) scanLocalMetaForUpload() error {
|
||||
defer st.flushPendingUploads()
|
||||
if st.activeUploadPaths == nil {
|
||||
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
|
||||
st.activeUploadPaths = active
|
||||
} else {
|
||||
st.activeUploadPaths = map[string]bool{}
|
||||
}
|
||||
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
|
||||
func (st *strmSyncState) scanLocalMetaForUpload() error {
|
||||
defer st.flushPendingUploads()
|
||||
if st.activeUploadPaths == nil {
|
||||
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
|
||||
st.activeUploadPaths = active
|
||||
} else {
|
||||
st.activeUploadPaths = map[string]bool{}
|
||||
}
|
||||
localRoot := filepath.Clean(st.p.LocalPath)
|
||||
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if path == localRoot {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-st.ctx.Done():
|
||||
return st.ctx.Err()
|
||||
default:
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(localRoot, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
if !st.isMetaExt(ext) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
st.mu.Lock()
|
||||
_, exists := st.remoteMeta["m:"+rel]
|
||||
st.mu.Unlock()
|
||||
if exists {
|
||||
// 网盘端已存在该元数据文件,跳过上传
|
||||
return nil
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
|
||||
st.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if st.activeUploadPaths != nil {
|
||||
st.activeUploadPaths[path] = true
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
task := &model.StrmUploadTask{
|
||||
SyncPathID: st.p.ID,
|
||||
AccountID: st.p.AccountID,
|
||||
Provider: st.p.Provider,
|
||||
FileName: filepath.Base(rel),
|
||||
LocalPath: path,
|
||||
RemotePath: st.remoteUploadPath(rel),
|
||||
Size: info.Size(),
|
||||
Status: model.StrmTaskPending,
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.pendingUploads = append(st.pendingUploads, task)
|
||||
shouldFlush := len(st.pendingUploads) >= 100
|
||||
st.rec.Uploaded++
|
||||
st.mu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
st.flushPendingUploads()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
localRoot := filepath.Clean(st.p.LocalPath)
|
||||
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if path == localRoot {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-st.ctx.Done():
|
||||
return st.ctx.Err()
|
||||
default:
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(localRoot, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
if !st.isMetaExt(ext) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
st.mu.Lock()
|
||||
_, exists := st.remoteMeta["m:"+rel]
|
||||
st.mu.Unlock()
|
||||
if exists {
|
||||
// 网盘端已存在该元数据文件,跳过上传
|
||||
return nil
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
|
||||
st.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if st.activeUploadPaths != nil {
|
||||
st.activeUploadPaths[path] = true
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
task := &model.StrmUploadTask{
|
||||
SyncPathID: st.p.ID,
|
||||
AccountID: st.p.AccountID,
|
||||
Provider: st.p.Provider,
|
||||
FileName: filepath.Base(rel),
|
||||
LocalPath: path,
|
||||
RemotePath: st.uploadRemoteTarget(rel),
|
||||
Size: info.Size(),
|
||||
Status: model.StrmTaskPending,
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.pendingUploads = append(st.pendingUploads, task)
|
||||
shouldFlush := len(st.pendingUploads) >= 100
|
||||
st.rec.Uploaded++
|
||||
st.mu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
st.flushPendingUploads()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
|
||||
func (st *strmSyncState) remoteUploadPath(rel string) string {
|
||||
@@ -1094,6 +1158,31 @@ func (st *strmSyncState) remoteUploadPath(rel string) string {
|
||||
return root + "/" + rel
|
||||
}
|
||||
|
||||
// uploadRemoteTarget 返回上传任务的目标远端描述。
|
||||
// - 115:返回父目录 cid(供 PutFileNamed 定位),基于 dirPathToID 把父目录相对路径映射到 cid。
|
||||
// - 网盘桥接(clouddrive2/openlist):返回完整远端路径。
|
||||
func (st *strmSyncState) uploadRemoteTarget(rel string) string {
|
||||
if st.p.Provider == model.StrmProvider115 {
|
||||
dir := rel
|
||||
if idx := strings.LastIndexByte(dir, '/'); idx >= 0 {
|
||||
dir = dir[:idx]
|
||||
} else {
|
||||
dir = ""
|
||||
}
|
||||
if dir == "" {
|
||||
// 文件在同步根目录下,父目录即 115 同步根目录 ID
|
||||
return st.p.RemotePath
|
||||
}
|
||||
if cid, ok := st.dirPathToID[dir]; ok && cid != "" {
|
||||
return cid
|
||||
}
|
||||
// 父目录未在缓存中(父目录可能本次未扫描到),降级为用户配置的同步根 cid,
|
||||
// 由上传端尽力处理(可能失败记日志,不影响下载)。
|
||||
return st.p.RemotePath
|
||||
}
|
||||
return st.remoteUploadPath(rel)
|
||||
}
|
||||
|
||||
// taskExists 检查是否已有同目录、同目标的进行中/已完成任务(避免重复入队)。
|
||||
func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
|
||||
ctx := st.ctx
|
||||
@@ -1109,6 +1198,14 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
|
||||
|
||||
// pruneLocal 清理本地多余 .strm 与元数据(远端已不存在),可选删除空目录。
|
||||
func (st *strmSyncState) pruneLocal() error {
|
||||
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
|
||||
// seenVideo/seenMeta 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地文件,
|
||||
// 进而触发"下次增量重新下载"的循环。此时跳过清理,仅做进度落库。
|
||||
if st.syncType == model.StrmSyncTypeIncremental && st.scanIncomplete.Load() {
|
||||
st.s.log.Warn("strm 增量同步跳过清理:本次远端扫描不完整,prune 已禁用",
|
||||
zap.String("path_id", st.p.ID))
|
||||
return nil
|
||||
}
|
||||
localRoot := filepath.Clean(st.p.LocalPath)
|
||||
var dirs []string
|
||||
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
||||
|
||||
+208
-133
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud"
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// testStrmService 构建带内存库的 StrmService。
|
||||
@@ -35,10 +38,10 @@ func testStrmService(t *testing.T) *StrmService {
|
||||
sqlDB.SetMaxOpenConns(4)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
if err := db.AutoMigrate(&model.StrmAccount{}, &model.StrmSyncPath{}, &model.StrmSyncRecord{},
|
||||
&model.StrmDownloadTask{}, &model.StrmUploadTask{}, &model.StrmDirCache{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.StrmAccount{}, &model.StrmSyncPath{}, &model.StrmSyncRecord{},
|
||||
&model.StrmDownloadTask{}, &model.StrmUploadTask{}, &model.StrmDirCache{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
if err := repos.Setting.Set(ctx, StrmSettingBaseURL, "http://test.local:8096"); err != nil {
|
||||
@@ -210,7 +213,6 @@ func TestStrmFullAndIncrementalSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestStrmCronMatches cron 表达式匹配。
|
||||
func TestStrmCronMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -494,142 +496,215 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
if walkErr != nil {
|
||||
t.Fatal(walkErr)
|
||||
}
|
||||
if strmCount != 5 {
|
||||
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
|
||||
}
|
||||
if strmCount != 5 {
|
||||
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStrmBatchEnqueueAndConcurrentClaim 测试大规模批量入库及多协程并发认领无死锁
|
||||
func TestStrmBatchEnqueueAndConcurrentClaim(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 批量插入 200 个下载任务
|
||||
tasks := make([]*model.StrmDownloadTask, 0, 200)
|
||||
for i := 0; i < 200; i++ {
|
||||
tasks = append(tasks, &model.StrmDownloadTask{
|
||||
SyncPathID: "test-sync-path",
|
||||
AccountID: "test-acct",
|
||||
Provider: model.StrmProvider115,
|
||||
FileName: filepath.Base(string(rune('a'+i%26))) + ".nfo",
|
||||
LocalPath: filepath.Join(t.TempDir(), string(rune('a'+i%26)), "test.nfo"),
|
||||
Status: model.StrmTaskPending,
|
||||
})
|
||||
}
|
||||
if err := svc.repo.StrmDownload.CreateInBatches(ctx, tasks, 50); err != nil {
|
||||
t.Fatalf("CreateInBatches failed: %v", err)
|
||||
}
|
||||
|
||||
// TestStrmBatchEnqueueAndConcurrentClaim 测试大规模批量入库及多协程并发认领无死锁
|
||||
func TestStrmBatchEnqueueAndConcurrentClaim(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
// 2. 验证 ActiveLocalPathMap
|
||||
activeMap, err := svc.repo.StrmDownload.GetActiveLocalPathMap(ctx, "test-sync-path")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveLocalPathMap failed: %v", err)
|
||||
}
|
||||
if len(activeMap) == 0 {
|
||||
t.Fatal("expected active local path map to have entries")
|
||||
}
|
||||
|
||||
// 1. 批量插入 200 个下载任务
|
||||
tasks := make([]*model.StrmDownloadTask, 0, 200)
|
||||
for i := 0; i < 200; i++ {
|
||||
tasks = append(tasks, &model.StrmDownloadTask{
|
||||
SyncPathID: "test-sync-path",
|
||||
AccountID: "test-acct",
|
||||
Provider: model.StrmProvider115,
|
||||
FileName: filepath.Base(string(rune('a'+i%26))) + ".nfo",
|
||||
LocalPath: filepath.Join(t.TempDir(), string(rune('a'+i%26)), "test.nfo"),
|
||||
Status: model.StrmTaskPending,
|
||||
})
|
||||
}
|
||||
if err := svc.repo.StrmDownload.CreateInBatches(ctx, tasks, 50); err != nil {
|
||||
t.Fatalf("CreateInBatches failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. 验证 ActiveLocalPathMap
|
||||
activeMap, err := svc.repo.StrmDownload.GetActiveLocalPathMap(ctx, "test-sync-path")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveLocalPathMap failed: %v", err)
|
||||
}
|
||||
if len(activeMap) == 0 {
|
||||
t.Fatal("expected active local path map to have entries")
|
||||
}
|
||||
|
||||
// 3. 模拟 6 个 worker 并发 ClaimPendingDownload
|
||||
claimedCount := 0
|
||||
var claimMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 6; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
batch, err := svc.repo.StrmDownload.ClaimPendingDownload(ctx, 10)
|
||||
if err != nil {
|
||||
t.Errorf("concurrent ClaimPendingDownload failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
claimMu.Lock()
|
||||
claimedCount += len(batch)
|
||||
claimMu.Unlock()
|
||||
// 3. 模拟 6 个 worker 并发 ClaimPendingDownload
|
||||
claimedCount := 0
|
||||
var claimMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 6; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
batch, err := svc.repo.StrmDownload.ClaimPendingDownload(ctx, 10)
|
||||
if err != nil {
|
||||
t.Errorf("concurrent ClaimPendingDownload failed: %v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if claimedCount != 200 {
|
||||
t.Fatalf("expected all 200 tasks claimed, got %d", claimedCount)
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
claimMu.Lock()
|
||||
claimedCount += len(batch)
|
||||
claimMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// TestStrmDuplicateFileConflictResolution 测试远端存在多个同名不同大小文件时,本地确定性仲裁,避免增量死循环
|
||||
func TestStrmDuplicateFileConflictResolution(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
if claimedCount != 200 {
|
||||
t.Fatalf("expected all 200 tasks claimed, got %d", claimedCount)
|
||||
}
|
||||
}
|
||||
|
||||
p := &model.StrmSyncPath{
|
||||
Base: model.Base{ID: "dup-test-path"},
|
||||
Provider: model.StrmProvider115,
|
||||
RemotePath: "root",
|
||||
LocalPath: localDir,
|
||||
DownloadMeta: true,
|
||||
}
|
||||
// TestStrmDuplicateFileConflictResolution 测试远端存在多个同名不同大小文件时,本地确定性仲裁,避免增量死循环
|
||||
func TestStrmDuplicateFileConflictResolution(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
}
|
||||
|
||||
// 模拟远端同目录下存在两个同名不同大小的 nfo 文件 (115 历史重复上传)
|
||||
// entry1: 较早文件 (MTime: 1000, Size: 100)
|
||||
entry1 := cloud.FileEntry{ID: "f1", Name: "test.nfo", Size: 100, MTime: 1000, PickCode: "p1"}
|
||||
// entry2: 较新文件 (MTime: 2000, Size: 200)
|
||||
entry2 := cloud.FileEntry{ID: "f2", Name: "test.nfo", Size: 200, MTime: 2000, PickCode: "p2"}
|
||||
|
||||
// 第一次全量处理:两者都在列表中
|
||||
st.handleMeta(entry1, "test.nfo", ".nfo")
|
||||
st.handleMeta(entry2, "test.nfo", ".nfo")
|
||||
st.flushPendingDownloads()
|
||||
|
||||
// 验证仲裁结果:最终只产生 1 个下载任务,且使用的是首个匹配项 (Size 100/p1)
|
||||
tasks, _, err := svc.repo.StrmDownload.List(context.Background(), "", 1, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 download task after conflict resolution, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Size != 100 || tasks[0].RemoteRef != "p1" {
|
||||
t.Fatalf("expected task with size 100/p1, got size=%d ref=%s", tasks[0].Size, tasks[0].RemoteRef)
|
||||
}
|
||||
|
||||
// 模拟该任务下载落盘完成
|
||||
writeFile(t, filepath.Join(localDir, "test.nfo"), strings.Repeat("x", 100))
|
||||
|
||||
// 第二次增量同步:两者再次依次扫描
|
||||
st2 := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
}
|
||||
st2.handleMeta(entry1, "test.nfo", ".nfo")
|
||||
st2.handleMeta(entry2, "test.nfo", ".nfo")
|
||||
st2.flushPendingDownloads()
|
||||
|
||||
// 验证:不会新增任何下载任务,NewMeta 为 0,增量跳过
|
||||
if st2.rec.NewMeta != 0 {
|
||||
t.Fatalf("expected 0 new meta on incremental sync, got %d", st2.rec.NewMeta)
|
||||
}
|
||||
p := &model.StrmSyncPath{
|
||||
Base: model.Base{ID: "dup-test-path"},
|
||||
Provider: model.StrmProvider115,
|
||||
RemotePath: "root",
|
||||
LocalPath: localDir,
|
||||
DownloadMeta: true,
|
||||
}
|
||||
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
}
|
||||
|
||||
// 模拟远端同目录下存在两个同名不同大小的 nfo 文件 (115 历史重复上传)
|
||||
// entry1: 较早文件 (MTime: 1000, Size: 100)
|
||||
entry1 := cloud.FileEntry{ID: "f1", Name: "test.nfo", Size: 100, MTime: 1000, PickCode: "p1"}
|
||||
// entry2: 较新文件 (MTime: 2000, Size: 200)
|
||||
entry2 := cloud.FileEntry{ID: "f2", Name: "test.nfo", Size: 200, MTime: 2000, PickCode: "p2"}
|
||||
|
||||
// 第一次全量处理:两者都在列表中
|
||||
st.handleMeta(entry1, "test.nfo", ".nfo")
|
||||
st.handleMeta(entry2, "test.nfo", ".nfo")
|
||||
st.flushPendingDownloads()
|
||||
|
||||
// 验证仲裁结果:最终只产生 1 个下载任务,且使用的是首个匹配项 (Size 100/p1)
|
||||
tasks, _, err := svc.repo.StrmDownload.List(context.Background(), "", 1, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 download task after conflict resolution, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Size != 100 || tasks[0].RemoteRef != "p1" {
|
||||
t.Fatalf("expected task with size 100/p1, got size=%d ref=%s", tasks[0].Size, tasks[0].RemoteRef)
|
||||
}
|
||||
|
||||
// 模拟该任务下载落盘完成
|
||||
writeFile(t, filepath.Join(localDir, "test.nfo"), strings.Repeat("x", 100))
|
||||
|
||||
// 第二次增量同步:两者再次依次扫描
|
||||
st2 := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
}
|
||||
st2.handleMeta(entry1, "test.nfo", ".nfo")
|
||||
st2.handleMeta(entry2, "test.nfo", ".nfo")
|
||||
st2.flushPendingDownloads()
|
||||
|
||||
// 验证:不会新增任何下载任务,NewMeta 为 0,增量跳过
|
||||
if st2.rec.NewMeta != 0 {
|
||||
t.Fatalf("expected 0 new meta on incremental sync, got %d", st2.rec.NewMeta)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestWalk115FlatAbortsOnDirResolveFailure 回归测试:115 开放平台 token 失效/目录详情
|
||||
// 解析失败时,同步必须中止而不是带着塌缩的 rel 继续处理,否则会导致本地大量元数据
|
||||
// 被误判为"云端不存在"而重复下载/上传,甚至误删本地文件(用户反馈"云盘没动却重下重传")。
|
||||
func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
|
||||
acct := &model.StrmAccount{
|
||||
Name: "fake115",
|
||||
Provider: "cloud115",
|
||||
Config: "{}",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &model.StrmSyncPath{
|
||||
Base: model.Base{ID: "abort-path"},
|
||||
AccountID: acct.ID,
|
||||
Provider: model.StrmProvider115,
|
||||
RemotePath: "0",
|
||||
LocalPath: localDir,
|
||||
}
|
||||
|
||||
// 115 mock:文件列表返回一个视频(父目录 999 不在缓存,需要 get_info),
|
||||
// get_info 恒返回 access_token 格式错误(40140123)→ 目录树解析失败。
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/ufile/files":
|
||||
w.Write([]byte(`{"state":true,"count":1,"data":[{"fid":"100","pid":"999","fc":1,"fn":"movie.mkv","pc":"pc1","upt":1700000000,"fs":1024}]}`))
|
||||
case "/open/folder/get_info":
|
||||
w.Write([]byte(`{"state":false,"code":40140123,"message":"access_token 格式错误"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
oldPro := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = api.URL
|
||||
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||
|
||||
oc := cloud115.NewOpenClient("app", "at", "rt")
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
provider: cloud.NewOpenAPI115("app", "at", "rt"),
|
||||
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1, DownloadMeta: false},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
syncType: model.StrmSyncTypeFull,
|
||||
dirCache: sync.Map{},
|
||||
seenVideo: map[string]bool{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
}
|
||||
err := st.walk115Flat(oc)
|
||||
if err == nil {
|
||||
t.Fatal("expected walk115Flat to abort on dir-resolve failure, got nil error")
|
||||
}
|
||||
|
||||
// 中止后不允许产生任何部分写入(本地不允许生成 .strm 文件)。
|
||||
var strmCount int
|
||||
_ = filepath.WalkDir(localDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() && strings.HasSuffix(d.Name(), ".strm") {
|
||||
strmCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if strmCount != 0 {
|
||||
t.Fatalf("expected no .strm written after abort, got %d", strmCount)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-179
@@ -1,8 +1,8 @@
|
||||
// Package service — subtitle handling.
|
||||
//
|
||||
// SubtitleService finds external subtitle files next to a media file AND
|
||||
// embedded text subtitle tracks inside the media container, exposing both as
|
||||
// WebVTT so the browser <track> element can load them directly.
|
||||
// SubtitleService finds external subtitle files next to a media file and
|
||||
// exposes them as WebVTT so the browser <track> element can load them
|
||||
// directly, or as the original bytes for Emby/Jellyfin clients.
|
||||
//
|
||||
// External-subtitle discovery rules (matching the legacy Python defaults):
|
||||
//
|
||||
@@ -12,25 +12,17 @@
|
||||
// ?lang=zh / ?lang=en.
|
||||
//
|
||||
// Supported extensions: .srt, .ass, .ssa, .vtt.
|
||||
//
|
||||
// Embedded subtitles are probed with ffprobe and exposed as
|
||||
// path "embedded:<stream-index>"; the browser endpoint extracts the stream
|
||||
// via ffmpeg into a cached .vtt file.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -71,6 +63,16 @@ var extToCodec = map[string]string{
|
||||
// relative; the caller should prepend /api/subtitles/<media_id>?path=...
|
||||
// when serializing for the frontend.
|
||||
func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,94 +120,9 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 容器内嵌文本字幕轨(MKV/MP4 等封装内的字幕流):本地真实文件才可
|
||||
// 探测提取;cloud:// 与 .strm 媒体跳过。探测失败静默忽略(无 ffprobe
|
||||
// 或没有字幕流都属正常)。
|
||||
if embedded, ok := s.discoverEmbeddedTracks(ctx, m.Path); ok {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// embeddedCodecOK 只暴露可提取为 WebVTT 的文本字幕编解码器;位图字幕
|
||||
// (PGS/DVDSUB/DVBSUB)浏览器无法渲染,跳过。
|
||||
func embeddedCodecOK(codec string) bool {
|
||||
switch strings.ToLower(codec) {
|
||||
case "subrip", "srt", "mov_text", "text", "webvtt", "ass", "ssa", "ttml", "sami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ffprobeSubtitleStream 是 ffprobe -show_streams 输出的字幕流字段。
|
||||
type ffprobeSubtitleStream struct {
|
||||
Index int `json:"index"`
|
||||
Codec string `json:"codec_name"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type ffprobeSubtitleContainer struct {
|
||||
Streams []ffprobeSubtitleStream `json:"streams"`
|
||||
}
|
||||
|
||||
// discoverEmbeddedTracks 用 ffprobe 探测媒体容器内的文本字幕轨。
|
||||
// 返回 (tracks, ok):ok=false 表示该媒体不适用(非本地文件/ffprobe 不可用)。
|
||||
func (s *SubtitleService) discoverEmbeddedTracks(ctx context.Context, mediaPath string) ([]SubtitleTrack, bool) {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaPath)), "cloud://") ||
|
||||
strings.HasSuffix(strings.ToLower(strings.TrimSpace(mediaPath)), ".strm") {
|
||||
return nil, false
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := os.Stat(mediaPath); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args are fixed probes.
|
||||
"-v", "error",
|
||||
"-select_streams", "s",
|
||||
"-show_entries", "stream=index,codec_name:stream_tags=language,title",
|
||||
"-of", "json",
|
||||
"--", mediaPath,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var container ffprobeSubtitleContainer
|
||||
if err := json.Unmarshal(out, &container); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tracks := make([]SubtitleTrack, 0, len(container.Streams))
|
||||
for _, stream := range container.Streams {
|
||||
if !embeddedCodecOK(stream.Codec) {
|
||||
continue
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(stream.Tags["language"]))
|
||||
if lang == "" {
|
||||
lang = "und"
|
||||
}
|
||||
label := stream.Tags["title"]
|
||||
if label == "" {
|
||||
label = lang
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: "内置字幕 · " + label,
|
||||
Path: "embedded:" + strconv.Itoa(stream.Index),
|
||||
Codec: stream.Codec,
|
||||
})
|
||||
}
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||
|
||||
@@ -222,18 +139,13 @@ func detectLang(name, base string) string {
|
||||
}
|
||||
|
||||
// Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are
|
||||
// converted minimally on the fly; embedded container tracks (path
|
||||
// "embedded:<index>") are extracted via ffmpeg into a cached .vtt.
|
||||
// Returns ErrSubtitleNotFound when the path is rejected (path traversal /
|
||||
// not in the media directory).
|
||||
// converted minimally on the fly. Returns ErrSubtitleNotFound when the
|
||||
// path is rejected (path traversal / not in the media directory).
|
||||
func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
return s.ServeEmbeddedToVTT(ctx, m.Path, sub, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -266,81 +178,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
return err
|
||||
}
|
||||
|
||||
// embeddedSubtitleCachePath 内嵌字幕提取后的 WebVTT 缓存路径
|
||||
// (按媒体路径哈希 + 轨道号定位,跨媒体互不干扰)。
|
||||
func (s *SubtitleService) embeddedSubtitleCachePath(mediaPath string, idx int) string {
|
||||
hash := fmt.Sprintf("%x", fnvHash(mediaPath))
|
||||
return filepath.Join(s.cfg.Cache.CacheDir, "subs", hash, fmt.Sprintf("s%d.vtt", idx))
|
||||
}
|
||||
|
||||
// ServeEmbeddedToVTT 把容器内第 idx 个字幕轨提取为 WebVTT 输出。
|
||||
// 提取结果缓存在 cache 目录,媒体文件更新(mtime 变化)后自动重新提取。
|
||||
func (s *SubtitleService) ServeEmbeddedToVTT(ctx context.Context, mediaPath, streamRef string, w io.Writer) error {
|
||||
idx, err := strconv.Atoi(strings.TrimPrefix(streamRef, "embedded:"))
|
||||
if err != nil || idx < 0 {
|
||||
return errors.New("invalid embedded subtitle index")
|
||||
}
|
||||
ffmpegBin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg 不可用,无法提取内嵌字幕:%w", err)
|
||||
}
|
||||
info, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return errors.New("media file not found")
|
||||
}
|
||||
|
||||
cachePath := s.embeddedSubtitleCachePath(mediaPath, idx)
|
||||
|
||||
if cached, statErr := os.Stat(cachePath); statErr == nil && !info.ModTime().After(cached.ModTime()) {
|
||||
f, openErr := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if openErr == nil {
|
||||
defer f.Close()
|
||||
_, copyErr := io.Copy(w, f)
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或媒体已更新:ffmpeg 提取到临时文件后原子改名。
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := cachePath + ".tmp"
|
||||
extractCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(extractCtx, ffmpegBin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args fixed extraction.
|
||||
"-v", "error", "-y",
|
||||
"-i", mediaPath,
|
||||
"-map", "0:s:"+strconv.Itoa(idx),
|
||||
"-f", "webvtt",
|
||||
tmp,
|
||||
)
|
||||
if out, runErr := cmd.CombinedOutput(); runErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("提取内嵌字幕失败(轨道 %d,可能为位图字幕或轨道无效):%s", idx, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// fnvHash 简单 32 位 FNV-1a 哈希,用于生成稳定的缓存子目录名。
|
||||
func fnvHash(s string) uint32 {
|
||||
var h uint32 = 2166136261
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint32(s[i])
|
||||
h *= 16777619
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
@@ -368,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return nil, nil
|
||||
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("query", query)
|
||||
q.Set("language", language)
|
||||
q.Set("include_adult", "false")
|
||||
q.Set("include_adult", "true")
|
||||
if year > 0 {
|
||||
q.Set("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
@@ -79,6 +79,12 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(p.Results) == 0 && year > 0 {
|
||||
// 移除年份限制重试一次,避免年份微小差异(如 2019 vs 2020)导致无搜索结果
|
||||
q.Del("year")
|
||||
u = base + "/search/movie?" + q.Encode()
|
||||
_ = t.getJSON(ctx, u, &p)
|
||||
}
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -137,7 +143,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return nil, nil
|
||||
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
|
||||
@@ -145,7 +151,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("query", query)
|
||||
q.Set("language", language)
|
||||
q.Set("include_adult", "false")
|
||||
q.Set("include_adult", "true")
|
||||
if year > 0 {
|
||||
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
@@ -159,6 +165,12 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(p.Results) == 0 && year > 0 {
|
||||
// 移除年份限制重试一次,避免年份微小差异导致无搜索结果
|
||||
q.Del("first_air_date_year")
|
||||
u = base + "/search/tv?" + q.Encode()
|
||||
_ = t.getJSON(ctx, u, &p)
|
||||
}
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package webui embeds the built React SPA so a single binary can serve the
|
||||
// UI without requiring a separate web/dist directory on disk. The embed
|
||||
// happens at compile time, so `web/dist` must exist before `go build` runs
|
||||
// (the CI pipeline builds it via `npm run build` first).
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// DistFS returns the embedded SPA build artifacts rooted at the directory
|
||||
// containing index.html (i.e. web/dist).
|
||||
func DistFS() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
+69
-18
@@ -1,6 +1,45 @@
|
||||
import { api } from './client'
|
||||
import type { AccessLog, Setting, User } from '../types'
|
||||
|
||||
export interface DatabaseStatus {
|
||||
type: 'sqlite' | 'postgres'
|
||||
dsn?: string
|
||||
db_path?: string
|
||||
open_conns: number
|
||||
in_use: number
|
||||
idle: number
|
||||
max_open_conns: number
|
||||
table_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface PostgresTestResult {
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
version?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DatabaseMigrationResult {
|
||||
success: boolean
|
||||
total_rows: number
|
||||
table_rows?: Record<string, number>
|
||||
duration_ms: number
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DatabaseConnectionPayload {
|
||||
type?: string
|
||||
dsn?: string
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
password?: string
|
||||
dbname?: string
|
||||
sslmode?: string
|
||||
}
|
||||
|
||||
export interface SystemUpdateStatus {
|
||||
image: string
|
||||
current_version?: string
|
||||
@@ -55,21 +94,33 @@ export const adminAPI = {
|
||||
|
||||
systemUpdateApply: () => api.post<SystemUpdateStatus>('/admin/system/update/apply').then((r) => r.data),
|
||||
|
||||
testAdultScraper: (payload: {
|
||||
engine?: string
|
||||
server_url?: string
|
||||
token?: string
|
||||
javdb_url?: string
|
||||
javbus_url?: string
|
||||
cookie?: string
|
||||
}) =>
|
||||
api
|
||||
.post<{
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
providers?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>('/admin/adult/test-scraper', payload)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
testAdultScraper: (payload: {
|
||||
engine?: string
|
||||
server_url?: string
|
||||
token?: string
|
||||
javdb_url?: string
|
||||
javbus_url?: string
|
||||
cookie?: string
|
||||
}) =>
|
||||
api
|
||||
.post<{
|
||||
success: boolean
|
||||
latency_ms?: number
|
||||
providers?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>('/admin/adult/test-scraper', payload)
|
||||
.then((r) => r.data),
|
||||
|
||||
getDatabaseStatus: () =>
|
||||
api.get<DatabaseStatus>('/admin/database/status').then((r) => r.data),
|
||||
|
||||
testDatabaseConnection: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<PostgresTestResult>('/admin/database/test', payload).then((r) => r.data),
|
||||
|
||||
migrateDatabase: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<DatabaseMigrationResult>('/admin/database/migrate', payload).then((r) => r.data),
|
||||
|
||||
saveDatabaseConfig: (payload: DatabaseConnectionPayload) =>
|
||||
api.post<{ message: string; type: string }>('/admin/database/save-config', payload).then((r) => r.data),
|
||||
}
|
||||
|
||||
+28
-5
@@ -81,11 +81,20 @@ export interface MediaMetadataUpdate {
|
||||
nsfw?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryWithPreview extends Library {
|
||||
total?: number
|
||||
cards?: SeriesCard[]
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean }) =>
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
|
||||
api
|
||||
.get<Library[]>('/libraries', {
|
||||
params: options?.includeHidden ? { include_hidden: 1 } : undefined,
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
@@ -102,8 +111,19 @@ export const libraryAPI = {
|
||||
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
|
||||
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
|
||||
|
||||
update: (id: string, payload: { cover_url: string }) =>
|
||||
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||
createPerSubfolder: (parentPath: string, type: string, coverURL = '') =>
|
||||
api.post<{ libraries: Library[] }>('/libraries', { path: parentPath, type, cover_url: coverURL, create_per_subfolder: true }).then((r) => r.data),
|
||||
|
||||
update: (
|
||||
id: string,
|
||||
payload: {
|
||||
cover_url?: string
|
||||
sort_order?: number | null
|
||||
carousel_enabled?: boolean | null
|
||||
},
|
||||
) => api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||
|
||||
reorder: (ids: string[]) => api.put('/libraries/reorder', { ids }).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
||||
|
||||
@@ -177,6 +197,9 @@ export const mediaAPI = {
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
getEpisodes: (id: string) =>
|
||||
api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data),
|
||||
|
||||
updateMetadata: (id: string, payload: MediaMetadataUpdate) =>
|
||||
api.patch<Media>(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from './client'
|
||||
import type { ScrapeQueueSnapshot } from '../types/scraper'
|
||||
|
||||
export interface EnqueueScrapeOptions {
|
||||
episode_images?: boolean
|
||||
episode_artwork?: boolean
|
||||
refresh_matched?: boolean
|
||||
include_matched?: boolean
|
||||
}
|
||||
|
||||
export const scraperAPI = {
|
||||
queue: (status?: string, page = 1, pageSize = 50) =>
|
||||
api
|
||||
.get<ScrapeQueueSnapshot>('/admin/scraper/queue', {
|
||||
params: { status, page, page_size: pageSize },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
cancelTask: (id: string) =>
|
||||
api.post(`/admin/scraper/queue/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
retryTask: (id: string) =>
|
||||
api.post(`/admin/scraper/queue/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteTask: (id: string) =>
|
||||
api.delete(`/admin/scraper/queue/${id}`).then((r) => r.data),
|
||||
|
||||
batchAction: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api
|
||||
.post<{ affected: number; action: string }>('/admin/scraper/queue/batch', { action, ids })
|
||||
.then((r) => r.data),
|
||||
|
||||
clearDone: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-done').then((r) => r.data),
|
||||
|
||||
clearFinished: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-finished').then((r) => r.data),
|
||||
|
||||
clearCanceled: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-canceled').then((r) => r.data),
|
||||
|
||||
retryFailed: () =>
|
||||
api.post<{ retried: number }>('/admin/scraper/queue/retry-failed').then((r) => r.data),
|
||||
|
||||
cancelPending: () =>
|
||||
api.post<{ canceled: number }>('/admin/scraper/queue/cancel-pending').then((r) => r.data),
|
||||
|
||||
enqueueLibrary: (libraryId: string, options?: EnqueueScrapeOptions) =>
|
||||
api
|
||||
.post<{ enqueued: number }>(`/admin/scraper/queue/enqueue-library/${libraryId}`, options ?? {})
|
||||
.then((r) => r.data),
|
||||
|
||||
enqueueAll: (options?: EnqueueScrapeOptions) =>
|
||||
api
|
||||
.post<{ enqueued: number }>('/admin/scraper/queue/enqueue-all', options ?? {})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
+22
-3
@@ -117,6 +117,13 @@ export const strmAPI = {
|
||||
.get<StrmSyncRecord[]>('/admin/strm/records', { params: pathId ? { path_id: pathId } : {} })
|
||||
.then((r) => r.data),
|
||||
|
||||
deleteRecord: (id: string) => api.delete(`/admin/strm/records/${id}`).then((r) => r.data),
|
||||
|
||||
clearRecords: (pathId?: string) =>
|
||||
api
|
||||
.delete<{ deleted: number }>('/admin/strm/records', { params: pathId ? { path_id: pathId } : {} })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 本地目录浏览(同步目录选择器) ────────────────────────
|
||||
listLocalDirs: (path?: string) =>
|
||||
api
|
||||
@@ -137,6 +144,12 @@ export const strmAPI = {
|
||||
retryDownload: (id: string) =>
|
||||
api.post(`/admin/strm/downloads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteDownload: (id: string) =>
|
||||
api.delete(`/admin/strm/downloads/${id}`).then((r) => r.data),
|
||||
|
||||
batchActionDownloads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api.post<{ affected: number; action: string }>('/admin/strm/downloads/batch', { action, ids }).then((r) => r.data),
|
||||
|
||||
clearDoneDownloads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/downloads/clear-done').then((r) => r.data),
|
||||
|
||||
@@ -162,12 +175,18 @@ export const strmAPI = {
|
||||
cancelUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
retryUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteUpload: (id: string) =>
|
||||
api.delete(`/admin/strm/uploads/${id}`).then((r) => r.data),
|
||||
|
||||
batchActionUploads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api.post<{ affected: number; action: string }>('/admin/strm/uploads/batch', { action, ids }).then((r) => r.data),
|
||||
|
||||
cancelPendingUploads: () =>
|
||||
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
|
||||
|
||||
clearCanceledUploads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
|
||||
|
||||
retryUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
|
||||
}
|
||||
@@ -67,6 +67,24 @@ export interface OrganizeResultSummary {
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FFmpegToolInfo {
|
||||
installed: boolean
|
||||
path?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface FFmpegToolsStatus {
|
||||
installing?: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
install_dir?: string
|
||||
target?: { os?: string; arch?: string; label?: string }
|
||||
ffmpeg?: FFmpegToolInfo
|
||||
ffprobe?: FFmpegToolInfo
|
||||
}
|
||||
|
||||
export const toolsAPI = {
|
||||
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
||||
api
|
||||
@@ -102,6 +120,14 @@ export const toolsAPI = {
|
||||
.post<{ message: string }>('/admin/notify/test', { title, body })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ffToolsStatus 查询 ffmpeg/ffprobe 的安装状态(路径/版本/是否正在安装)。
|
||||
ffToolsStatus: () =>
|
||||
api.get<FFmpegToolsStatus>('/admin/tools/ffmpeg/status').then((r) => r.data),
|
||||
|
||||
// installFFTools 触发后台下载安装 ffmpeg/ffprobe(自动匹配当前平台)。
|
||||
installFFTools: () =>
|
||||
api.post<FFmpegToolsStatus>('/admin/tools/ffmpeg/install').then((r) => r.data),
|
||||
|
||||
// repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的
|
||||
// {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。
|
||||
// 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。
|
||||
|
||||
@@ -33,6 +33,9 @@ const StrmDownloadQueuePage = lazy(() =>
|
||||
const StrmUploadQueuePage = lazy(() =>
|
||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||
)
|
||||
const ScraperQueuePage = lazy(() =>
|
||||
import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })),
|
||||
)
|
||||
|
||||
export type AppRoute = {
|
||||
path?: string
|
||||
@@ -62,5 +65,6 @@ export const appRoutes: AppRoute[] = [
|
||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||
{ path: 'admin', element: <AdminPage />, adminOnly: true },
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ExternalPlayerButton({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={compact ? '使用外部播放器播放' : undefined}
|
||||
disabled={loading}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -50,7 +51,7 @@ export function ExternalPlayerButton({
|
||||
}}
|
||||
className={
|
||||
compact
|
||||
? 'rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50'
|
||||
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
|
||||
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react'
|
||||
|
||||
import { toolsAPI, type FFmpegToolInfo, type FFmpegToolsStatus } from '../api/tools'
|
||||
|
||||
// FFToolsPanel 展示 ffmpeg/ffprobe 的安装状态,并提供「一键下载安装」:
|
||||
// 后端自动匹配当前运行环境(OS + 架构),下载到 data 目录并写入设置,
|
||||
// 无需手动填写路径。
|
||||
export function FFToolsPanel({ onInstalled }: { onInstalled?: () => void }) {
|
||||
const [status, setStatus] = useState<FFmpegToolsStatus | null>(null)
|
||||
const [working, setWorking] = useState(false)
|
||||
const pollingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await toolsAPI.ffToolsStatus())
|
||||
} catch {
|
||||
// 网络波动时保持旧状态,不打断页面
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => undefined)
|
||||
}, [load])
|
||||
|
||||
const installing = Boolean(status?.installing)
|
||||
|
||||
// 安装进行中:轮询状态直到结束,完成后刷新设置页(路径字段自动填入)。
|
||||
useEffect(() => {
|
||||
if (!installing || pollingRef.current) return
|
||||
pollingRef.current = true
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const next = await toolsAPI.ffToolsStatus()
|
||||
setStatus(next)
|
||||
if (!next.installing) {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
if (next.error) {
|
||||
toast.error(`FFmpeg 下载安装失败:${next.error}`)
|
||||
} else {
|
||||
toast.success('FFmpeg / FFprobe 下载安装完成,系统已自动使用')
|
||||
onInstalled?.()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败继续等下一轮
|
||||
}
|
||||
}, 1500)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
pollingRef.current = false
|
||||
}
|
||||
}, [installing, onInstalled])
|
||||
|
||||
const install = async () => {
|
||||
setWorking(true)
|
||||
try {
|
||||
const next = await toolsAPI.installFFTools()
|
||||
setStatus(next)
|
||||
if (next.error && !next.installing) {
|
||||
toast.error(next.error)
|
||||
} else if (next.installing) {
|
||||
toast('开始下载,完成后自动生效')
|
||||
} else {
|
||||
toast.success('已检测到可用工具')
|
||||
onInstalled?.()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
(err as { message?: string })?.message ??
|
||||
'启动下载失败',
|
||||
)
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = working || installing
|
||||
const targetLabel = status?.target?.label ?? '当前平台'
|
||||
const ffmpegInstalled = Boolean(status?.ffmpeg?.installed)
|
||||
const ffprobeInstalled = Boolean(status?.ffprobe?.installed)
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-display text-lg font-bold text-ink-600">FFmpeg / FFprobe 工具</div>
|
||||
<div className="text-xs text-sand-500">
|
||||
当前平台:{targetLabel} · 安装目录:
|
||||
<span className="font-mono">{status?.install_dir ?? '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={install}
|
||||
disabled={busy}
|
||||
className="neon-button shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
{busy ? '下载安装中…' : '下载并安装'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<ToolRow name="FFmpeg" info={status?.ffmpeg} />
|
||||
<ToolRow name="FFprobe" info={status?.ffprobe} />
|
||||
</div>
|
||||
|
||||
{(status?.message || status?.error) && (
|
||||
<div
|
||||
className={
|
||||
status?.error
|
||||
? 'rounded-xl bg-red-50 px-3 py-2 text-xs text-red-600'
|
||||
: 'rounded-xl bg-sand-100/70 px-3 py-2 text-xs text-sand-500'
|
||||
}
|
||||
>
|
||||
{status?.error || status?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ffmpegInstalled || !ffprobeInstalled ? (
|
||||
<div className="text-xs text-sand-500">
|
||||
未检测到可用工具。点击「下载并安装」后,服务端会自动匹配当前系统下载对应版本,安装完成后无需手动填写路径。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolRow({ name, info }: { name: string; info?: FFmpegToolInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-2">
|
||||
{info?.installed ? (
|
||||
<CheckCircle2 size={16} className="shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle size={16} className="shrink-0 text-red-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-ink-600">
|
||||
{name} {info?.installed ? '' : '(未安装)'}
|
||||
</div>
|
||||
{info?.installed && (
|
||||
<div className="truncate font-mono text-[11px] text-ink-50" title={info.path}>
|
||||
{info.path}
|
||||
</div>
|
||||
)}
|
||||
{info?.version && <div className="truncate text-[11px] text-sand-500">{info.version}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
ListVideo,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +37,15 @@ type PlayerControlsProps = {
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
@@ -44,6 +56,15 @@ export function PlayerControls({
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
hasPrevEpisode = false,
|
||||
hasNextEpisode = false,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen = false,
|
||||
hasPlaylist = false,
|
||||
onTogglePlaylist,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
@@ -69,6 +90,7 @@ export function PlayerControls({
|
||||
const isScrubbingRef = useRef(false)
|
||||
const subtitleMenuOpenRef = useRef(false)
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
@@ -86,6 +108,10 @@ export function PlayerControls({
|
||||
danmakuOpenRef.current = danmakuOpen
|
||||
}, [danmakuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
@@ -112,14 +138,16 @@ export function PlayerControls({
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -137,7 +165,7 @@ export function PlayerControls({
|
||||
if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current) return
|
||||
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
}
|
||||
@@ -201,7 +229,7 @@ export function PlayerControls({
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen) {
|
||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
|
||||
setUiVisible(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
} else {
|
||||
@@ -211,7 +239,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -297,14 +325,43 @@ export function PlayerControls({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-white">
|
||||
{/* 上一集 */}
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
disabled={!hasPrevEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasPrevEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasPrevEpisode ? (prevEpisodeTitle ? `上一集:${prevEpisodeTitle} ([)` : '上一集 ([)') : '没有上一集'}
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={playing ? '暂停' : '播放'}
|
||||
title={playing ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{playing ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 下一集 */}
|
||||
<button
|
||||
onClick={onNextEpisode}
|
||||
disabled={!hasNextEpisode}
|
||||
className={`rounded-full p-1.5 transition ${
|
||||
hasNextEpisode
|
||||
? 'hover:bg-white/15 text-white cursor-pointer'
|
||||
: 'text-white/30 cursor-not-allowed opacity-40'
|
||||
}`}
|
||||
title={hasNextEpisode ? (nextEpisodeTitle ? `下一集:${nextEpisodeTitle} (])` : '下一集 (])') : '没有下一集'}
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -383,6 +440,27 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选集 / 播放列表按钮 */}
|
||||
{onTogglePlaylist && (
|
||||
<button
|
||||
onClick={onTogglePlaylist}
|
||||
disabled={!hasPlaylist}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(!hasPlaylist
|
||||
? 'bg-white/5 text-white/30 cursor-not-allowed opacity-50'
|
||||
: playlistOpen
|
||||
? 'bg-rose-500 text-white hover:bg-rose-600'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
|
||||
>
|
||||
<ListVideo size={15} />
|
||||
选集
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 弹幕按钮 */}
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
@@ -401,7 +479,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={muted || volume === 0 ? '取消静音' : '静音'}
|
||||
title={muted || volume === 0 ? '取消静音 (M)' : '静音 (M)'}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
@@ -419,7 +497,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
|
||||
export type SeasonGroup = {
|
||||
season: number
|
||||
episodes: Media[]
|
||||
}
|
||||
|
||||
type PlayerPlaylistPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentMediaId: string
|
||||
episodes: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
}
|
||||
|
||||
export function PlayerPlaylistPanel({
|
||||
open,
|
||||
onClose,
|
||||
currentMediaId,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null)
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 按季分组
|
||||
const seasonGroups = useMemo<SeasonGroup[]>(() => {
|
||||
if (!episodes || episodes.length === 0) return []
|
||||
const seasonsMap = new Map<number, Media[]>()
|
||||
for (const ep of episodes) {
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
if (!seasonsMap.has(s)) seasonsMap.set(s, [])
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, list]) => ({ season, episodes: list }))
|
||||
}, [episodes])
|
||||
|
||||
// 当前播放所在季
|
||||
const currentSeason = useMemo(() => {
|
||||
const found = episodes.find((e) => e.id === currentMediaId)
|
||||
if (!found) return seasonGroups[0]?.season ?? 1
|
||||
return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1)
|
||||
}, [episodes, currentMediaId, seasonGroups])
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(currentSeason)
|
||||
|
||||
// 当当前播放媒体改变或打开面板时,默认选中当前媒体所在的季
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedSeason(currentSeason)
|
||||
}
|
||||
}, [open, currentSeason])
|
||||
|
||||
// 当面板打开时,自动平滑滚动到当前播放集的位置
|
||||
useEffect(() => {
|
||||
if (open && activeItemRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, selectedSeason, currentMediaId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const currentGroup = seasonGroups.find((g) => g.season === selectedSeason) ?? seasonGroups[0]
|
||||
const listToDisplay = currentGroup ? currentGroup.episodes : episodes
|
||||
|
||||
const filteredEpisodes = filterText.trim()
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ListVideo size={17} className="text-rose-400" />
|
||||
<span>选集列表</span>
|
||||
<span className="font-mono text-xs font-normal text-white/50">
|
||||
({episodes.length} 集)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{seasonGroups.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
|
||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||
const isSelected = selectedSeason === season
|
||||
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => {
|
||||
setSelectedSeason(season)
|
||||
setFilterText('')
|
||||
}}
|
||||
className={`relative flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition ${
|
||||
isSelected
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
|
||||
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
||||
{isPlayingThisSeason && !isSelected && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索/过滤单集(当单集数量较多时) */}
|
||||
{listToDisplay.length > 10 && (
|
||||
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
||||
<Search size={13} className="text-white/40 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="搜索集数或标题…"
|
||||
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText('')}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数列表 */}
|
||||
<div
|
||||
ref={listContainerRef}
|
||||
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
|
||||
>
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</div>
|
||||
) : (
|
||||
filteredEpisodes.map((ep) => {
|
||||
const isPlaying = ep.id === currentMediaId
|
||||
const displayTitle = getEpisodeTitle(ep, listToDisplay)
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
ref={isPlaying ? activeItemRef : null}
|
||||
onClick={() => onSelectEpisode(ep)}
|
||||
className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${
|
||||
isPlaying
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-white'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85'
|
||||
}`}
|
||||
>
|
||||
{/* 封面/集号 */}
|
||||
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Film size={16} className="text-white/40" />
|
||||
)}
|
||||
|
||||
{/* 正在播放动效 / 集数徽标 */}
|
||||
{isPlaying ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
|
||||
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={14} className="text-white fill-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角标显示集数 */}
|
||||
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
|
||||
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题 & 时长 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={`truncate text-xs font-medium ${
|
||||
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{displayTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="font-mono">第 {ep.episode_num} 集</span>
|
||||
)}
|
||||
{durationText && <span>{durationText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlaying && (
|
||||
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
|
||||
<Check size={13} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
|
||||
const pathTitle = seriesTitleFromPath(ep.path)
|
||||
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
|
||||
|
||||
const siblingTitles = new Set(
|
||||
siblings
|
||||
.map((item) => normalizeEpisodeTitle(item.title))
|
||||
.filter(Boolean),
|
||||
)
|
||||
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
|
||||
}
|
||||
|
||||
function normalizeEpisodeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FolderOpen,
|
||||
Library,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
@@ -22,6 +23,7 @@ export type LayoutNavItem = {
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
{ to: '/scraper/queue', label: '刮削队列', icon: Sparkles, adminOnly: true },
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
|
||||
@@ -13,9 +13,11 @@ export function AdminLibraryPanel() {
|
||||
type={createForm.type}
|
||||
coverURL={createForm.coverURL}
|
||||
roots={createForm.roots}
|
||||
createPerSubfolder={createForm.createPerSubfolder}
|
||||
onNameChange={createForm.setName}
|
||||
onTypeChange={createForm.setType}
|
||||
onCoverURLChange={createForm.setCoverURL}
|
||||
onCreatePerSubfolderChange={createForm.setCreatePerSubfolder}
|
||||
onRootChange={createForm.updateRoot}
|
||||
onAddRoot={createForm.addRoot}
|
||||
onRemoveRoot={createForm.removeRoot}
|
||||
@@ -33,6 +35,8 @@ export function AdminLibraryPanel() {
|
||||
onRemoveLibrary={libraryActions.removeLibrary}
|
||||
onAddLibraryRoot={libraryActions.addLibraryRoot}
|
||||
onEditLibraryCover={libraryActions.editLibraryCover}
|
||||
onToggleCarousel={libraryActions.toggleCarouselLibrary}
|
||||
onReorder={libraryActions.reorderLibraries}
|
||||
/>
|
||||
<LibraryStorageStats />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ type CreateFormProps = {
|
||||
type: string
|
||||
coverURL: string
|
||||
roots: RootDraft[]
|
||||
createPerSubfolder: boolean
|
||||
onNameChange: (value: string) => void
|
||||
onTypeChange: (value: string) => void
|
||||
onCoverURLChange: (value: string) => void
|
||||
onCreatePerSubfolderChange: (value: boolean) => void
|
||||
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
||||
onAddRoot: () => void
|
||||
onRemoveRoot: (index: number) => void
|
||||
@@ -23,9 +25,11 @@ export function AdminLibraryCreateForm({
|
||||
type,
|
||||
coverURL,
|
||||
roots,
|
||||
createPerSubfolder,
|
||||
onNameChange,
|
||||
onTypeChange,
|
||||
onCoverURLChange,
|
||||
onCreatePerSubfolderChange,
|
||||
onRootChange,
|
||||
onAddRoot,
|
||||
onRemoveRoot,
|
||||
@@ -50,9 +54,9 @@ export function AdminLibraryCreateForm({
|
||||
<>
|
||||
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
required={!createPerSubfolder}
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
placeholder={createPerSubfolder ? '父级媒体库名(批量模式忽略)' : '名称'}
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
@@ -81,15 +85,31 @@ export function AdminLibraryCreateForm({
|
||||
onRemove={onRemoveRoot}
|
||||
/>
|
||||
))}
|
||||
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={onAddRoot}>
|
||||
<Plus size={16} /> 添加路径
|
||||
</button>
|
||||
{!createPerSubfolder && (
|
||||
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={onAddRoot}>
|
||||
<Plus size={16} /> 添加路径
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
支持直接点选或手动输入;名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库。
|
||||
</p>
|
||||
<label className="md:col-span-4 flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-brand-400"
|
||||
checked={createPerSubfolder}
|
||||
onChange={(e) => onCreatePerSubfolderChange(e.target.checked)}
|
||||
/>
|
||||
<span>按目录下每个子文件夹各建一个媒体库(媒体库名取子文件夹名)</span>
|
||||
</label>
|
||||
{createPerSubfolder && (
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
批处理模式:仅取上方第一个路径作为父级目录,会为其中每个子文件夹分别创建媒体库,可自选类型用于整体推断。
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建 / 追加路径
|
||||
{createPerSubfolder ? '按目录批量创建' : '新建 / 追加路径'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { Folder, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
@@ -18,11 +19,15 @@ type LibraryTableProps = {
|
||||
onRemoveLibrary: (library: Library) => void
|
||||
onAddLibraryRoot: (library: Library, path?: string, name?: string) => void
|
||||
onEditLibraryCover: (library: Library) => void
|
||||
onToggleCarousel: (library: Library) => void
|
||||
onReorder: (orderedLibs: Library[]) => void
|
||||
}
|
||||
|
||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
|
||||
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
|
||||
const handleSelectRootPath = (selectedPath: string) => {
|
||||
if (browsingRoot) {
|
||||
@@ -40,15 +45,39 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleReorder = (fromId: string, overId: string) => {
|
||||
if (fromId === overId) return
|
||||
const copy = [...libs]
|
||||
const fromIndex = copy.findIndex((l) => l.id === fromId)
|
||||
const overIndex = copy.findIndex((l) => l.id === overId)
|
||||
if (fromIndex < 0 || overIndex < 0) return
|
||||
const [moved] = copy.splice(fromIndex, 1)
|
||||
copy.splice(overIndex, 0, moved)
|
||||
actions.onReorder(copy)
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
dragOverId.current = null
|
||||
setDraggingId(null)
|
||||
if (draggingId && overId !== draggingId) {
|
||||
handleReorder(draggingId, overId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<table className="w-full min-w-[960px] text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-8 text-center" title="拖动排序">
|
||||
<GripVertical size={13} className="mx-auto text-gray-300" />
|
||||
</th>
|
||||
<th className="w-28 py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th className="w-20">类型</th>
|
||||
<th className="w-24">轮播</th>
|
||||
<th className="w-12 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -59,6 +88,23 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
library={library}
|
||||
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
|
||||
onOpenAddRoot={() => setAddingRootLib(library)}
|
||||
dragging={draggingId === library.id}
|
||||
dragOver={dragOverId.current === library.id}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragOverId.current = null
|
||||
setDraggingId(library.id)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current !== library.id) dragOverId.current = library.id
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragOverId.current = null
|
||||
setDraggingId(null)
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, library.id)}
|
||||
{...actions}
|
||||
/>
|
||||
))}
|
||||
@@ -90,11 +136,37 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||
library: Library
|
||||
onBrowseRoot: (root: LibraryRoot) => void
|
||||
onOpenAddRoot: () => void
|
||||
dragging?: boolean
|
||||
dragOver?: boolean
|
||||
onDragStart?: (e: DragEvent) => void
|
||||
onDragOver?: (e: DragEvent) => void
|
||||
onDragEnd?: () => void
|
||||
onDrop?: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver, onDragEnd, onDrop, ...actions }: LibraryTableRowProps) {
|
||||
return (
|
||||
<tr className="border-t border-gray-200">
|
||||
<tr
|
||||
draggable={false}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
className={`border-t border-gray-200 transition-colors ${
|
||||
dragging ? 'bg-primary-400/10 opacity-60' : dragOver ? 'bg-primary-400/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
className="inline-flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition hover:bg-gray-100 hover:text-brand-500 active:cursor-grabbing"
|
||||
title="拖动以调整媒体库显示顺序"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
|
||||
@@ -105,6 +177,9 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
<LibraryRootsCell library={library} {...actions} />
|
||||
</td>
|
||||
<td className="px-3 text-ink-100">{library.type}</td>
|
||||
<td className="py-2 text-ink-100">
|
||||
<CarouselToggle library={library} onToggleCarousel={actions.onToggleCarousel} />
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<LibraryActionsCell library={library} {...actions} />
|
||||
</td>
|
||||
@@ -112,6 +187,25 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) {
|
||||
const on = Boolean(library.carousel_enabled)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCarousel(library)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-semibold transition ${
|
||||
on
|
||||
? 'border-brand-500/50 bg-brand-500/10 text-brand-500'
|
||||
: 'border-gray-300 bg-white text-ink-50 hover:border-gray-400'
|
||||
}`}
|
||||
title={on ? '已开启首页海报轮播(点击关闭)' : '未开启首页海报轮播(点击开启)'}
|
||||
>
|
||||
<span className={`h-3.5 w-3.5 rounded-full ${on ? 'bg-brand-500' : 'bg-gray-300'}`} />
|
||||
{on ? '参与轮播' : '未参与'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
|
||||
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
|
||||
return (
|
||||
@@ -255,18 +349,90 @@ function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAdd
|
||||
}
|
||||
|
||||
function ActionMenu({ label, children }: { label: string; children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top?: number; bottom?: number; right: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleMenu = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const estimatedHeight = 180
|
||||
const openUpward = spaceBelow < estimatedHeight && rect.top > estimatedHeight
|
||||
setCoords({
|
||||
top: openUpward ? undefined : rect.bottom + 4,
|
||||
bottom: openUpward ? window.innerHeight - rect.top + 4 : undefined,
|
||||
right: window.innerWidth - rect.right,
|
||||
})
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleClickOutside = (e: globalThis.MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
const handleScrollOrResize = () => setIsOpen(false)
|
||||
window.addEventListener('mousedown', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScrollOrResize, true)
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<details className="group relative inline-flex justify-end">
|
||||
<summary
|
||||
className="flex h-8 w-8 cursor-pointer list-none items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-50 transition hover:border-primary-400/50 hover:text-brand-500 [&::-webkit-details-marker]:hidden"
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleMenu}
|
||||
className={`inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition ${
|
||||
isOpen
|
||||
? 'border-brand-500 bg-brand-500/10 text-brand-500'
|
||||
: 'border-gray-200 bg-white text-ink-50 hover:border-primary-400/50 hover:text-brand-500'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</summary>
|
||||
<div className="absolute right-0 top-9 z-30 min-w-28 rounded-lg border border-gray-200 bg-white p-1 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top !== undefined ? `${coords.top}px` : undefined,
|
||||
bottom: coords.bottom !== undefined ? `${coords.bottom}px` : undefined,
|
||||
right: `${coords.right}px`,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className="min-w-32 rounded-xl border border-gray-200/90 bg-white p-1.5 shadow-2xl backdrop-blur"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,17 +449,19 @@ function MenuButton({
|
||||
onClick: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.currentTarget.closest('details')?.removeAttribute('open')
|
||||
onClick()
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition ${
|
||||
danger ? 'text-red-500 hover:bg-red-50' : 'text-ink-100 hover:bg-gray-50 hover:text-brand-500'
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-medium transition ${
|
||||
danger
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: 'text-ink-100 hover:bg-gray-100 hover:text-brand-500'
|
||||
}`}
|
||||
title={label}
|
||||
onClick={handleClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span>{children}</span>
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Activity,
|
||||
ArrowRightLeft,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
HardDrive,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
adminAPI,
|
||||
type DatabaseConnectionPayload,
|
||||
type DatabaseMigrationResult,
|
||||
type DatabaseStatus,
|
||||
type PostgresTestResult,
|
||||
} from '../api/admin'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
export function DatabaseSettingsPanel() {
|
||||
const [status, setStatus] = useState<DatabaseStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mode, setMode] = useState<'form' | 'dsn'>('form')
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<DatabaseConnectionPayload>({
|
||||
host: '127.0.0.1',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: '',
|
||||
dbname: 'mmtl',
|
||||
sslmode: 'disable',
|
||||
dsn: '',
|
||||
})
|
||||
|
||||
// 测试与操作状态
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<PostgresTestResult | null>(null)
|
||||
const [migrating, setMigrating] = useState(false)
|
||||
const [migrationResult, setMigrationResult] = useState<DatabaseMigrationResult | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refreshStatus = () => {
|
||||
setLoading(true)
|
||||
return adminAPI
|
||||
.getDatabaseStatus()
|
||||
.then(setStatus)
|
||||
.catch((err) => toast.error('获取数据库状态失败: ' + (err.message || '网络错误')))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const getPayload = (): DatabaseConnectionPayload => {
|
||||
if (mode === 'dsn') {
|
||||
return { type: 'postgres', dsn: formData.dsn?.trim() || '' }
|
||||
}
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: formData.host?.trim() || '',
|
||||
port: Number(formData.port) || 5432,
|
||||
user: formData.user?.trim() || '',
|
||||
password: formData.password || '',
|
||||
dbname: formData.dbname?.trim() || 'mmtl',
|
||||
sslmode: formData.sslmode || 'disable',
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
const payload = getPayload()
|
||||
if (mode === 'form' && (!payload.host || !payload.user)) {
|
||||
toast.error('请填写 PostgreSQL 主机和用户名')
|
||||
return
|
||||
}
|
||||
if (mode === 'dsn' && !payload.dsn) {
|
||||
toast.error('请填写 PostgreSQL DSN')
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await adminAPI.testDatabaseConnection(payload)
|
||||
setTestResult(res)
|
||||
if (res.success) {
|
||||
toast.success(`连接成功!延迟: ${res.latency_ms}ms`)
|
||||
} else {
|
||||
toast.error(res.error || '连接失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message || '测试连接异常'
|
||||
setTestResult({ success: false, error: errorMsg })
|
||||
toast.error(errorMsg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMigrate = async () => {
|
||||
const payload = getPayload()
|
||||
if (status?.type === 'postgres') {
|
||||
const ok = await confirmAction({
|
||||
title: '覆盖/同步确认',
|
||||
message: '当前已经处于 PostgreSQL 模式,继续迁移将覆盖/合并目标库的数据,确定继续吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
const ok = await confirmAction({
|
||||
title: '开始数据库迁移',
|
||||
message: '即将把当前 SQLite 数据库中的所有媒体、用户、播放记录、设置等全量迁移到目标 PostgreSQL 数据库。确定开始吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
setMigrating(true)
|
||||
setMigrationResult(null)
|
||||
try {
|
||||
const res = await adminAPI.migrateDatabase(payload)
|
||||
setMigrationResult(res)
|
||||
if (res.success) {
|
||||
toast.success(`数据迁移完成!共迁移 ${res.total_rows} 条记录`)
|
||||
} else {
|
||||
toast.error(res.error || '数据迁移失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message || '迁移发生错误'
|
||||
setMigrationResult({ success: false, total_rows: 0, duration_ms: 0, error: errorMsg })
|
||||
toast.error(errorMsg)
|
||||
} finally {
|
||||
setMigrating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAndSwitch = async () => {
|
||||
const payload = getPayload()
|
||||
const ok = await confirmAction({
|
||||
title: '切换数据库',
|
||||
message: '保存后系统配置将更新为使用 PostgreSQL。需要重启 MMTL 服务使新数据库生效。确定保存吗?',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await adminAPI.saveDatabaseConfig(payload)
|
||||
toast.success(res.message || '数据库配置已保存,请重启服务生效')
|
||||
refreshStatus()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || err.message || '保存配置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">数据库设置与迁移</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
管理系统底层数据库,支持在 SQLite(本地嵌入式)与 PostgreSQL(高性能关系库)之间平滑切换与数据迁移
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-sand-200/50 px-3 py-1.5 text-xs text-ink-100 hover:bg-sand-200 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前数据库状态卡片 */}
|
||||
<div className="glass-panel p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={18} className="text-brand-500" />
|
||||
<span className="font-medium text-sm text-ink-600">当前运行引擎</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ' +
|
||||
(status?.type === 'postgres'
|
||||
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20')
|
||||
}
|
||||
>
|
||||
<Zap size={12} />
|
||||
{status?.type === 'postgres' ? 'PostgreSQL' : 'SQLite (WAL 优化)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">存储位置 / 连接</p>
|
||||
<p className="font-mono text-ink-600 break-all">
|
||||
{status?.type === 'postgres'
|
||||
? status.dsn || '配置的 PostgreSQL 实例'
|
||||
: status?.db_path || './data/mmtl.db'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">连接池活跃 / 最大</p>
|
||||
<p className="font-mono text-ink-600">
|
||||
活跃: {status?.in_use ?? 0} · 空闲: {status?.idle ?? 0} · 上限:{' '}
|
||||
{status?.max_open_conns ?? 16}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-sand-200/30 p-3">
|
||||
<p className="text-sand-500">核心表记录概览</p>
|
||||
<p className="text-ink-600">
|
||||
媒体: {status?.table_counts?.media ?? 0} · 用户: {status?.table_counts?.users ?? 0} ·
|
||||
播放记录: {status?.table_counts?.playback_histories ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置 PostgreSQL */}
|
||||
<div className="glass-panel p-5 space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-brand-500" />
|
||||
<span className="font-medium text-sm text-ink-600">配置目标 PostgreSQL</span>
|
||||
</div>
|
||||
<div className="flex rounded-lg bg-sand-200/40 p-0.5 text-xs">
|
||||
<button
|
||||
onClick={() => setMode('form')}
|
||||
className={
|
||||
'rounded-md px-3 py-1 transition ' +
|
||||
(mode === 'form'
|
||||
? 'bg-brand-500 text-white font-medium shadow-sm'
|
||||
: 'text-sand-500 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
分段表单
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('dsn')}
|
||||
className={
|
||||
'rounded-md px-3 py-1 transition ' +
|
||||
(mode === 'dsn'
|
||||
? 'bg-brand-500 text-white font-medium shadow-sm'
|
||||
: 'text-sand-500 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
完整 DSN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'form' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">主机地址 (Host)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.host || ''}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
placeholder="例如 127.0.0.1 或 postgres"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">端口 (Port)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port || 5432}
|
||||
onChange={(e) => setFormData({ ...formData, port: Number(e.target.value) })}
|
||||
placeholder="5432"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">数据库名 (Database)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dbname || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dbname: e.target.value })}
|
||||
placeholder="mmtl"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">用户名 (User)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.user || ''}
|
||||
onChange={(e) => setFormData({ ...formData, user: e.target.value })}
|
||||
placeholder="postgres"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">密码 (Password)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password || ''}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-sand-500">SSL 模式 (SSL Mode)</label>
|
||||
<select
|
||||
value={formData.sslmode || 'disable'}
|
||||
onChange={(e) => setFormData({ ...formData, sslmode: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<option value="disable">disable (关闭 SSL)</option>
|
||||
<option value="require">require (强制 SSL)</option>
|
||||
<option value="verify-ca">verify-ca (验证 CA)</option>
|
||||
<option value="verify-full">verify-full (严格验证证书与主机名)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<label className="text-xs font-medium text-sand-500">
|
||||
PostgreSQL DSN 字符串 (URL 格式)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dsn || ''}
|
||||
onChange={(e) => setFormData({ ...formData, dsn: e.target.value })}
|
||||
placeholder="postgres://user:password@127.0.0.1:5432/mmtl?sslmode=disable"
|
||||
className="w-full font-mono text-xs rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试结果卡片 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={
|
||||
'flex items-start gap-2.5 rounded-lg p-3.5 text-xs ' +
|
||||
(testResult.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-rose-500/10 border border-rose-500/20 text-rose-400')
|
||||
}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<HelpCircle size={16} className="mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-semibold">
|
||||
{testResult.success ? `连接测试通过 (${testResult.latency_ms} ms)` : '连接测试未通过'}
|
||||
</p>
|
||||
{testResult.version && <p className="text-ink-100">{testResult.version}</p>}
|
||||
{testResult.error && <p className="text-rose-300 font-mono">{testResult.error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 迁移结果卡片 */}
|
||||
{migrationResult && (
|
||||
<div
|
||||
className={
|
||||
'rounded-lg p-3.5 text-xs space-y-2 ' +
|
||||
(migrationResult.success
|
||||
? 'bg-blue-500/10 border border-blue-500/20 text-blue-400'
|
||||
: 'bg-rose-500/10 border border-rose-500/20 text-rose-400')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck size={16} />
|
||||
<span>{migrationResult.message || (migrationResult.success ? '迁移完成' : '迁移失败')}</span>
|
||||
{migrationResult.success && (
|
||||
<span className="text-sand-500 text-[11px]">
|
||||
(耗时: {migrationResult.duration_ms} ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{migrationResult.table_rows && Object.keys(migrationResult.table_rows).length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 pt-1 font-mono text-[11px] text-ink-100">
|
||||
{Object.entries(migrationResult.table_rows).map(([tbl, count]) => (
|
||||
<div key={tbl} className="rounded bg-sand-200/40 px-2 py-1 flex justify-between">
|
||||
<span>{tbl}:</span>
|
||||
<span className="font-bold text-brand-400">{count} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{migrationResult.error && (
|
||||
<p className="text-rose-300 font-mono">{migrationResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || migrating || saving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-sand-200/60 px-4 py-2 text-xs font-medium text-ink-600 hover:bg-sand-200 disabled:opacity-50"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="animate-spin" /> : <Activity size={14} />}
|
||||
测试连接
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMigrate}
|
||||
disabled={testing || migrating || saving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-xs font-medium text-brand-400 hover:bg-primary-500/20 disabled:opacity-50"
|
||||
>
|
||||
{migrating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowRightLeft size={14} />
|
||||
)}
|
||||
一键数据迁移到 PostgreSQL
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAndSwitch}
|
||||
disabled={testing || migrating || saving}
|
||||
className="neon-button text-xs disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存并切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+24
-74
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
HomeCarouselSection,
|
||||
@@ -13,21 +13,12 @@ import {
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -38,68 +29,26 @@ export function HomePage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
|
||||
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[]),
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then((rows) => asArray<HistoryItem>(rows))
|
||||
.catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||
|
||||
// Set default selected libraries if none saved yet
|
||||
setSelectedLibraryIds((current) => {
|
||||
if (current.length > 0) return current
|
||||
const allIds = libs.map((l) => l.id)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return allIds
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
|
||||
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
|
||||
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
|
||||
// 改用 /series 分页拉取全部聚合后的剧集卡片。
|
||||
if (isSeriesType(lib.type)) {
|
||||
const cards: SeriesCard[] = []
|
||||
let total = 0
|
||||
const pageSize = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
|
||||
const pageItems = asArray<SeriesCard>(data?.items)
|
||||
cards.push(...pageItems)
|
||||
total = data?.total ?? cards.length
|
||||
if (cards.length >= total || pageItems.length < pageSize) break
|
||||
}
|
||||
return { id: lib.id, cards, items: [], total }
|
||||
}
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
return {
|
||||
id: lib.id,
|
||||
cards,
|
||||
items,
|
||||
total: page?.total ?? items.length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (cancelled) return
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const res of results) {
|
||||
if (res.status === 'fulfilled' && res.value) {
|
||||
mapData[res.value.id] = {
|
||||
cards: res.value.cards,
|
||||
items: res.value.items,
|
||||
total: res.value.total,
|
||||
}
|
||||
for (const lib of libs) {
|
||||
mapData[lib.id] = {
|
||||
cards: lib.cards ?? [],
|
||||
items: [],
|
||||
total: lib.total ?? 0,
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
@@ -132,16 +81,18 @@ export function HomePage() {
|
||||
return counts
|
||||
}, [libraries, libraryData])
|
||||
|
||||
// Compute items to show in the Hero Carousel
|
||||
// Compute items to show in the Hero Carousel. Only libraries flagged
|
||||
// carousel_enabled contribute; a library's items array is empty for
|
||||
// series-type libs (loaded via /series), so gate on cards instead.
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds =
|
||||
selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id)
|
||||
const effectiveSelectedIds = libraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
|
||||
for (const libId of effectiveSelectedIds) {
|
||||
const data = libraryData[libId]
|
||||
if (data && data.items.length > 0) {
|
||||
// Pick representative items from series cards or raw items
|
||||
if (data && data.cards.length > 0) {
|
||||
for (const card of data.cards) {
|
||||
if (hasArtwork(card.rep)) {
|
||||
candidateMedia.push(card.rep)
|
||||
@@ -150,9 +101,8 @@ export function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by artwork score / rating or shuffle / interleave
|
||||
// Fallback to all loaded items with artwork
|
||||
if (candidateMedia.length === 0) {
|
||||
// Fallback to all loaded items with artwork
|
||||
for (const lib of libraries) {
|
||||
const data = libraryData[lib.id]
|
||||
if (data) {
|
||||
@@ -164,7 +114,7 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [selectedLibraryIds, libraries, libraryData])
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const empty =
|
||||
!loading &&
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LibrariesEmptyState,
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import { isSeriesLibraryType, latestLibraryCards, type LibraryPreview } from './librariesPageModel'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
@@ -20,24 +20,15 @@ export function LibrariesPage() {
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const libs = await libraryAPI.list()
|
||||
const rows = await Promise.all(libs.map(async (library) => {
|
||||
try {
|
||||
if (isSeriesLibraryType(library.type)) {
|
||||
const [seriesPage, mediaPage] = await Promise.all([
|
||||
libraryAPI.listSeries(library.id, 1, 10),
|
||||
libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }),
|
||||
])
|
||||
return { library, items: [], total: mediaPage.total, cards: seriesPage.items ?? [] } satisfies LibraryPreview
|
||||
}
|
||||
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
|
||||
const cards = latestLibraryCards(page.items)
|
||||
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
|
||||
} catch {
|
||||
return { library, items: [], total: 0, cards: [] } satisfies LibraryPreview
|
||||
}
|
||||
}))
|
||||
setPreviews(rows)
|
||||
const libs = await libraryAPI.list({ withPreview: true })
|
||||
setPreviews(
|
||||
libs.map((library) => ({
|
||||
library,
|
||||
items: [],
|
||||
total: library.total ?? 0,
|
||||
cards: library.cards ?? [],
|
||||
})),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Tv } from 'lucide-react'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -73,6 +73,10 @@ export function LibrariesHeader({
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button type="button" onClick={onManageLibraries} className="btn-outline">
|
||||
管理媒体库
|
||||
</button>
|
||||
|
||||
@@ -113,21 +113,23 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
|
||||
@@ -55,10 +55,10 @@ export function LibraryPageHeader({
|
||||
className="h-10"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline">
|
||||
{scraping ? '刮削中…' : '刮削元数据'}
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
@@ -66,7 +66,7 @@ export function LibraryPageHeader({
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮本库'}
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,40 +62,53 @@ export function LibrarySeriesEpisodes({
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
ep.episode_num || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{episodeDisplayTitle(ep, visibleEpisodes)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-sand-200 bg-white p-2.5 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-brand-50/70 border border-sand-200/60"
|
||||
title={displayTitle}
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600 transition-colors group-hover:text-brand-600" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500 whitespace-nowrap">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,17 +116,29 @@ export function LibrarySeriesEpisodes({
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
if (epTitle && !looksLikeSeriesTitle(ep, epTitle, siblings)) {
|
||||
mainTitle = epTitle
|
||||
} else {
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
mainTitle = mediaTitle
|
||||
}
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
if (ep.episode_num > 0) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
return mainTitle || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Film,
|
||||
FolderOpen,
|
||||
HeartHandshake,
|
||||
Layers,
|
||||
Loader2,
|
||||
Music,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { imageURL } from '../api/client'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const SETTING_KEY_CAROUSEL = 'home.carousel_libraries'
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={20} />,
|
||||
movies: <Film size={20} />,
|
||||
tv: <Tv size={20} />,
|
||||
series: <Tv size={20} />,
|
||||
anime: <Layers size={20} />,
|
||||
shows: <Tv size={20} />,
|
||||
variety: <Tv size={20} />,
|
||||
music: <Music size={20} />,
|
||||
adult: <HeartHandshake size={20} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
movies: '电影',
|
||||
tv: '剧集',
|
||||
series: '剧集',
|
||||
anime: '动漫',
|
||||
shows: '综艺',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function LibrarySettingsPanel() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryCards, setLibraryCards] = useState<Record<string, SeriesCard[]>>({})
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, settings] = await Promise.all([
|
||||
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||
adminAPI.listSettings().catch(() => [] as Setting[]),
|
||||
])
|
||||
|
||||
const libList = Array.isArray(libs) ? libs : []
|
||||
setLibraries(libList)
|
||||
|
||||
// 异步拉取各个媒体库的前几个条目用于封面展示
|
||||
Promise.allSettled(
|
||||
libList.map(async (lib) => {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 10)
|
||||
const items = Array.isArray(page?.items) ? page.items : []
|
||||
return { id: lib.id, cards: groupSeries(items) }
|
||||
}),
|
||||
).then((results) => {
|
||||
const map: Record<string, SeriesCard[]> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
map[r.value.id] = r.value.cards
|
||||
}
|
||||
}
|
||||
setLibraryCards(map)
|
||||
})
|
||||
|
||||
// 优先读取系统配置,其次读取 localStorage,默认全选
|
||||
const settingItem = Array.isArray(settings)
|
||||
? settings.find((s) => s.key === SETTING_KEY_CAROUSEL)
|
||||
: undefined
|
||||
|
||||
let initialIds: string[] | null = null
|
||||
if (settingItem?.value) {
|
||||
try {
|
||||
initialIds = JSON.parse(settingItem.value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialIds) {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
if (saved) initialIds = JSON.parse(saved)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(initialIds)) {
|
||||
setSelectedIds(initialIds)
|
||||
} else {
|
||||
setSelectedIds(libList.map((l) => l.id))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const toggleLibrary = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
|
||||
setDirty(true)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(libraries.map((l) => l.id))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds([])
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const jsonValue = JSON.stringify(selectedIds)
|
||||
await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
toast.success('海报轮播设置已保存')
|
||||
setDirty(false)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存设置失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-6">
|
||||
{/* 头部说明与快捷操作 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200/80 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500 mt-0.5">
|
||||
<SlidersHorizontal size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">首页海报轮播设置</h3>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>全选</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deselectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>清空</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体库列表卡片 */}
|
||||
{libraries.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-sand-500">
|
||||
暂无可用媒体库,请先添加媒体库后再配置海报轮播。
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{libraries.map((lib) => {
|
||||
const isSelected = selectedIds.includes(lib.id)
|
||||
const cards = libraryCards[lib.id] || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lib.id}
|
||||
onClick={() => toggleLibrary(lib.id)}
|
||||
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${
|
||||
isSelected
|
||||
? 'border-brand-500/60 bg-primary-400/10 shadow-sm'
|
||||
: 'border-gray-200 bg-white/70 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`grid h-12 w-16 shrink-0 gap-0.5 overflow-hidden rounded-xl bg-gray-100 shadow-inner transition-colors ${
|
||||
artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
|
||||
} ${
|
||||
isSelected ? 'ring-2 ring-brand-500/30' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-ink-50">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={20} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-display text-sm font-bold text-ink-600">
|
||||
{lib.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold border border-gray-200 bg-gray-50 text-ink-50">
|
||||
{TYPE_LABELS[lib.type] || '自定义'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
{isSelected ? '已启用轮播' : '未参与轮播'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border transition-colors ${
|
||||
isSelected
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-gray-300 bg-white text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部保存按钮 */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200/80">
|
||||
<span className="text-xs text-sand-500">
|
||||
已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import type Hls from 'hls.js'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -80,6 +81,10 @@ export function PlayerPage() {
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
|
||||
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
@@ -272,14 +277,160 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [media])
|
||||
|
||||
// ESC = back.
|
||||
// 加载剧集/播放列表
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let canceled = false
|
||||
mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((res) => {
|
||||
if (canceled) return
|
||||
setPlaylistEpisodes(res.items ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (canceled) return
|
||||
if (media && (media.display_library_id || media.library_id)) {
|
||||
const libId = media.display_library_id || media.library_id
|
||||
const seriesKey = getSeriesKey(media)
|
||||
if (seriesKey) {
|
||||
libraryAPI
|
||||
.listSeriesEpisodes(libId, seriesKey)
|
||||
.then((res) => {
|
||||
if (!canceled) setPlaylistEpisodes(res.items ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setPlaylistEpisodes([])
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setPlaylistEpisodes([])
|
||||
})
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [id, media])
|
||||
|
||||
const currentEpisodeIndex = useMemo(() => {
|
||||
if (!media || playlistEpisodes.length === 0) return -1
|
||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
||||
}, [media, playlistEpisodes])
|
||||
|
||||
const prevEpisode = useMemo(() => {
|
||||
if (currentEpisodeIndex > 0) {
|
||||
return playlistEpisodes[currentEpisodeIndex - 1]
|
||||
}
|
||||
return null
|
||||
}, [currentEpisodeIndex, playlistEpisodes])
|
||||
|
||||
const nextEpisode = useMemo(() => {
|
||||
if (currentEpisodeIndex >= 0 && currentEpisodeIndex < playlistEpisodes.length - 1) {
|
||||
return playlistEpisodes[currentEpisodeIndex + 1]
|
||||
}
|
||||
return null
|
||||
}, [currentEpisodeIndex, playlistEpisodes])
|
||||
|
||||
const prevEpisodeTitle = useMemo(() => {
|
||||
return prevEpisode ? formatEpisodeDisplay(prevEpisode, playlistEpisodes) : ''
|
||||
}, [prevEpisode, playlistEpisodes])
|
||||
|
||||
const nextEpisodeTitle = useMemo(() => {
|
||||
return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : ''
|
||||
}, [nextEpisode, playlistEpisodes])
|
||||
|
||||
const playEpisode = useCallback(
|
||||
(target: Media) => {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/play/${target.id}`,
|
||||
search: location.search,
|
||||
},
|
||||
{ state: location.state },
|
||||
)
|
||||
},
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
const handlePrevEpisode = useCallback(() => {
|
||||
if (prevEpisode) {
|
||||
playEpisode(prevEpisode)
|
||||
}
|
||||
}, [prevEpisode, playEpisode])
|
||||
|
||||
const handleNextEpisode = useCallback(() => {
|
||||
if (nextEpisode) {
|
||||
playEpisode(nextEpisode)
|
||||
}
|
||||
}, [nextEpisode, playEpisode])
|
||||
|
||||
const togglePlaylistOpen = useCallback(() => {
|
||||
setPlaylistOpen((prev) => {
|
||||
const next = !prev
|
||||
if (next) setDanmakuOpen(false)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleDanmakuOpen = useCallback(() => {
|
||||
setDanmakuOpen((prev) => {
|
||||
const next = !prev
|
||||
if (next) setPlaylistOpen(false)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 视频播放结束时自动播放下一集
|
||||
useEffect(() => {
|
||||
if (!ref.current || !nextEpisode) return
|
||||
const video = ref.current
|
||||
const onEnded = () => {
|
||||
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
|
||||
playEpisode(nextEpisode)
|
||||
}
|
||||
video.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
video.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [nextEpisode, nextEpisodeTitle, playEpisode])
|
||||
|
||||
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') goBack()
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (playlistOpen) {
|
||||
setPlaylistOpen(false)
|
||||
return
|
||||
}
|
||||
if (danmakuOpen) {
|
||||
setDanmakuOpen(false)
|
||||
return
|
||||
}
|
||||
goBack()
|
||||
} else if (e.key === '[' || (e.shiftKey && e.key.toLowerCase() === 'p')) {
|
||||
if (prevEpisode) {
|
||||
e.preventDefault()
|
||||
handlePrevEpisode()
|
||||
}
|
||||
} else if (e.key === ']' || (e.shiftKey && e.key.toLowerCase() === 'n')) {
|
||||
if (nextEpisode) {
|
||||
e.preventDefault()
|
||||
handleNextEpisode()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [goBack])
|
||||
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
@@ -344,9 +495,27 @@ export function PlayerPage() {
|
||||
danmakuSearch={danmakuSearch}
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={() => setDanmakuOpen((v) => !v)}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
open={danmakuOpen}
|
||||
@@ -373,3 +542,41 @@ export function PlayerPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
|
||||
const pathTitle = seriesTitleFromPath(ep.path)
|
||||
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
|
||||
|
||||
const siblingTitles = new Set(
|
||||
siblings
|
||||
.map((item) => normalizeEpisodeTitle(item.title))
|
||||
.filter(Boolean),
|
||||
)
|
||||
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
|
||||
}
|
||||
|
||||
function normalizeEpisodeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ type PlayerVideoStageProps = {
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
playlistPanel?: ReactNode
|
||||
hasPrevEpisode?: boolean
|
||||
hasNextEpisode?: boolean
|
||||
onPrevEpisode?: () => void
|
||||
onNextEpisode?: () => void
|
||||
prevEpisodeTitle?: string
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -49,6 +60,16 @@ export function PlayerVideoStage({
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
hasNextEpisode,
|
||||
onPrevEpisode,
|
||||
onNextEpisode,
|
||||
prevEpisodeTitle,
|
||||
nextEpisodeTitle,
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
onTogglePlaylist,
|
||||
}: PlayerVideoStageProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||
@@ -114,10 +135,11 @@ export function PlayerVideoStage({
|
||||
setActiveCueText('')
|
||||
return
|
||||
}
|
||||
const trackIdx = subtitleIndex
|
||||
|
||||
const updateCue = () => {
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selectedEl = trackEls[subtitleIndex]
|
||||
const selectedEl = trackEls[trackIdx]
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
@@ -151,11 +173,11 @@ export function PlayerVideoStage({
|
||||
const tt = el.track
|
||||
if (tt) {
|
||||
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
|
||||
tt.mode = i === subtitleIndex ? 'hidden' : 'disabled'
|
||||
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
|
||||
}
|
||||
})
|
||||
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (!selected) return
|
||||
|
||||
const tt = selected.track
|
||||
@@ -184,7 +206,7 @@ export function PlayerVideoStage({
|
||||
video.removeEventListener('seeked', updateCue)
|
||||
video.removeEventListener('playing', updateCue)
|
||||
const trackEls = Array.from(video.querySelectorAll('track'))
|
||||
const selected = trackEls[subtitleIndex]
|
||||
const selected = trackEls[trackIdx]
|
||||
if (selected) {
|
||||
selected.removeEventListener('load', updateCue)
|
||||
if (selected.track) {
|
||||
@@ -241,7 +263,7 @@ export function PlayerVideoStage({
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === subtitleIndex}
|
||||
default={subtitleIndex === index}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
@@ -282,8 +304,18 @@ export function PlayerVideoStage({
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
onNextEpisode={onNextEpisode}
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
{playlistPanel}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
|
||||
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
|
||||
{ key: 'pending', label: '排队中', icon: Clock, color: 'text-gray-500' },
|
||||
{ key: 'running', label: '刮削中', icon: PlayCircle, color: 'text-brand-500' },
|
||||
{ key: 'done', label: '已匹配', icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
{ key: 'failed', label: '未匹配/失败', icon: AlertCircle, color: 'text-rose-500' },
|
||||
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
|
||||
]
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={14} className="text-blue-500" />,
|
||||
tv: <Tv size={14} className="text-purple-500" />,
|
||||
anime: <Layers size={14} className="text-emerald-500" />,
|
||||
adult: <Film size={14} className="text-rose-500" />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ScraperQueuePage() {
|
||||
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setIsRefreshing(true)
|
||||
try {
|
||||
const status = filter === 'all' ? undefined : filter
|
||||
const data = await scraperAPI.queue(status, page, PAGE_SIZE)
|
||||
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
|
||||
if (page > tp) {
|
||||
setPage(tp)
|
||||
return
|
||||
}
|
||||
setTotalPages(tp)
|
||||
setSnapshot(data)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (showLoading) setIsRefreshing(false)
|
||||
}
|
||||
},
|
||||
[filter, page],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [autoRefresh, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
|
||||
// Row actions
|
||||
const cancelTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.cancelTask(task.id)
|
||||
toast.success('已取消刮削任务')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const retryTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.retryTask(task.id)
|
||||
toast.success('已重新推入刮削队列')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.deleteTask(task.id)
|
||||
toast.success('已删除刮削记录')
|
||||
if (detailTask?.id === task.id) setDetailTask(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Batch actions
|
||||
const runSelectedBatch = async (action: 'retry' | 'cancel' | 'delete') => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const actionText = action === 'retry' ? '重新刮削' : action === 'cancel' ? '取消' : '删除'
|
||||
if (action === 'delete' && !window.confirm(`确定删除选中的 ${ids.length} 条刮削记录?`)) return
|
||||
if (action === 'cancel' && !window.confirm(`确定取消选中的 ${ids.length} 个刮削任务?`)) return
|
||||
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await scraperAPI.batchAction(action, ids)
|
||||
toast.success(`已成功${actionText} ${res.affected} 项`)
|
||||
setSelectedIds(new Set())
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runGlobalBatch = async (
|
||||
action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>,
|
||||
confirmMsg?: string,
|
||||
) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await action()
|
||||
if (res.deleted !== undefined) toast.success(`已清空 ${res.deleted} 条记录`)
|
||||
else if (res.retried !== undefined) toast.success(`已重新入队 ${res.retried} 个任务`)
|
||||
else if (res.canceled !== undefined) toast.success(`已取消 ${res.canceled} 个任务`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue all libraries
|
||||
const handleEnqueueAll = async () => {
|
||||
if (!window.confirm('确定将全库所有未匹配或需要更新的媒体重新推入刮削队列?')) return
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await scraperAPI.enqueueAll({ include_matched: false, refresh_matched: false, episode_images: true })
|
||||
toast.success(`已将 ${res.enqueued} 个媒体项推入刮削队列`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and search
|
||||
const tasks = snapshot?.tasks ?? []
|
||||
const filteredTasks = useMemo(() => {
|
||||
let list = tasks
|
||||
if (filter !== 'all') {
|
||||
list = list.filter((t) => t.status === filter)
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.media_title.toLowerCase().includes(q) ||
|
||||
t.matched_title.toLowerCase().includes(q) ||
|
||||
t.library_name.toLowerCase().includes(q) ||
|
||||
t.media_path.toLowerCase().includes(q) ||
|
||||
(t.error && t.error.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [tasks, filter, search])
|
||||
|
||||
const counts = snapshot?.counts
|
||||
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
|
||||
const failedCount = counts?.failed ?? 0
|
||||
const allCurrentChecked =
|
||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allCurrentChecked) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Header */}
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||
<Sparkles size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">刮削队列</h1>
|
||||
{autoRefresh && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
媒体元数据在线识别与海报/剧照下载进度(TMDb / 豆瓣 / Bangumi / TheTVDB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={handleEnqueueAll}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-500/40 bg-brand-500/10 px-3 py-2 text-xs font-semibold text-brand-500 shadow-sm transition hover:bg-brand-500/20 disabled:opacity-50"
|
||||
title="将所有媒体库未刮削媒体加入队列"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
<span>全库重新刮削</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-semibold transition ${
|
||||
autoRefresh
|
||||
? 'border-emerald-300/50 bg-emerald-50 text-emerald-700 hover:bg-emerald-100/70'
|
||||
: 'border-gray-200 bg-white text-ink-50 hover:bg-gray-50'
|
||||
}`}
|
||||
title={autoRefresh ? '点击暂停自动刷新' : '点击开启 3 秒自动轮询'}
|
||||
>
|
||||
<Clock size={13} />
|
||||
<span>自动刷新: {autoRefresh ? '开启' : '已暂停'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={() => refresh(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50"
|
||||
title="手动刷新"
|
||||
>
|
||||
<RefreshCw size={13} className={isRefreshing ? 'animate-spin text-brand-500' : ''} />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
|
||||
<details className="relative inline-block">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
|
||||
<Trash2 size={13} className="text-sand-500" />
|
||||
<span>批量操作</span>
|
||||
</summary>
|
||||
<div className="absolute right-0 top-10 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
|
||||
{failedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>重试所有失败 ({failedCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTaskCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
<Ban size={13} />
|
||||
<span>取消所有进行中 ({activeTaskCount})</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
<CheckCircle2 size={13} className="text-emerald-500" />
|
||||
<span>清空已完成记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
<Ban size={13} className="text-amber-500" />
|
||||
<span>清空已取消记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>清空全部历史记录</span>
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 2. Status Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{FILTERS.map((item) => {
|
||||
const count =
|
||||
item.key === 'all'
|
||||
? (counts?.pending ?? 0) +
|
||||
(counts?.running ?? 0) +
|
||||
(counts?.done ?? 0) +
|
||||
(counts?.failed ?? 0) +
|
||||
(counts?.canceled ?? 0)
|
||||
: counts?.[item.key] ?? 0
|
||||
const isActive = filter === item.key
|
||||
const ItemIcon = item.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(item.key)
|
||||
setPage(1)
|
||||
}}
|
||||
className={`flex flex-col justify-between rounded-2xl border p-3.5 text-left transition-all duration-200 select-none ${
|
||||
isActive
|
||||
? 'border-brand-500 bg-primary-400/10 shadow-sm ring-2 ring-brand-500/20'
|
||||
: 'border-gray-200 bg-white/80 hover:border-gray-300 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-sand-500">
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
<ItemIcon size={14} className={item.color} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className={`font-display text-2xl font-black ${item.color}`}>{count}</span>
|
||||
<span className="text-[10px] text-sand-400 font-medium">项</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 3. Search & Batch Actions */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索媒体标题、匹配结果、媒体库或错误信息…"
|
||||
className="h-9 w-full rounded-xl border border-gray-200 bg-white pl-9 pr-8 text-xs text-ink-600 placeholder:text-gray-400 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-400 hover:text-ink-600"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
|
||||
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
|
||||
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('retry')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-white px-2 py-1 font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
重试选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('cancel')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 bg-white px-2 py-1 font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
<Ban size={12} />
|
||||
取消选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('delete')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-rose-300 bg-white px-2 py-1 font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="p-1 text-gray-400 hover:text-ink-600"
|
||||
title="清空选择"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Table */}
|
||||
<div className="glass-panel overflow-hidden !p-0 shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-ink-50">
|
||||
<Loader2 className="animate-spin text-brand-500" size={28} />
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="py-16 text-center text-xs text-sand-500">
|
||||
{search
|
||||
? '没有找到符合搜索条件的刮削任务'
|
||||
: filter === 'all'
|
||||
? '刮削队列为空,暂无进行或排队中的任务'
|
||||
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allCurrentChecked}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
|
||||
title="全选 / 反选本页"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3">媒体文件</th>
|
||||
<th className="px-3 py-3">所属媒体库</th>
|
||||
<th className="px-3 py-3">刮削匹配结果</th>
|
||||
<th className="px-3 py-3">识别源</th>
|
||||
<th className="px-3 py-3">状态</th>
|
||||
<th className="px-3 py-3">时间</th>
|
||||
<th className="px-3 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredTasks.map((task) => {
|
||||
const status = taskStatusMeta(task.status)
|
||||
const isSelected = selectedIds.has(task.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`transition-colors hover:bg-primary-400/5 ${
|
||||
isSelected ? 'bg-primary-400/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectRow(task.id)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Media title & path */}
|
||||
<td className="max-w-[240px] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{TYPE_ICONS[task.media_type] || <Film size={14} className="text-gray-400" />}
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline block"
|
||||
title={task.media_title}
|
||||
>
|
||||
{task.media_title}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-gray-400 block" title={task.media_path}>
|
||||
{task.media_path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Library */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-semibold text-ink-100">
|
||||
{task.library_name || '媒体库'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Scraped matched result */}
|
||||
<td className="max-w-[240px] px-3 py-2.5">
|
||||
{task.matched_title ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{task.poster_url ? (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-7 rounded bg-gray-100 flex items-center justify-center text-gray-400 shrink-0">
|
||||
<ImageIcon size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-ink-600 truncate block text-xs">
|
||||
{task.matched_title}
|
||||
</span>
|
||||
{task.matched_year > 0 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{task.matched_year} 年
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-sand-400 font-mono">
|
||||
{task.status === 'pending' || task.status === 'running'
|
||||
? '等待识别…'
|
||||
: '未匹配到结果'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Provider */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
|
||||
{task.provider ? (
|
||||
<span className="rounded bg-brand-500/10 border border-brand-500/20 px-1.5 py-0.5 text-[10px] font-bold text-brand-500">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status & Error */}
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={`inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'running' && (
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
)}
|
||||
{task.status === 'done'
|
||||
? '已匹配'
|
||||
: task.status === 'failed'
|
||||
? '未匹配'
|
||||
: status.label}
|
||||
</span>
|
||||
{task.error && (
|
||||
<span
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="cursor-pointer truncate max-w-[180px] text-[10px] text-rose-500 hover:underline"
|
||||
title={task.error}
|
||||
>
|
||||
{task.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Time */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-50 whitespace-nowrap">
|
||||
{formatTime(task.created_at)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-2.5 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelTask(task)}
|
||||
className="rounded-lg border border-amber-200 px-2 py-1 text-xs font-semibold text-amber-600 transition hover:bg-amber-50"
|
||||
title="取消刮削"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' ||
|
||||
task.status === 'canceled' ||
|
||||
task.status === 'done') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryTask(task)}
|
||||
className="rounded-lg border border-primary-400/50 bg-primary-400/5 px-2 py-1 text-xs font-semibold text-brand-500 transition hover:bg-primary-400/15"
|
||||
title="重新刮削"
|
||||
>
|
||||
重刮
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTask(task)}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. Pagination */}
|
||||
{(snapshot?.total ?? 0) > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200/80 bg-gray-50/40 px-4 py-3">
|
||||
<span className="text-xs text-sand-500">
|
||||
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 6. Task Detail Modal */}
|
||||
{detailTask && (
|
||||
<ScrapeDetailModal
|
||||
task={detailTask}
|
||||
onClose={() => setDetailTask(null)}
|
||||
onRetry={retryTask}
|
||||
onCancel={cancelTask}
|
||||
onDelete={deleteTask}
|
||||
onCopy={copyText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: ScrapeTask
|
||||
onClose: () => void
|
||||
onRetry: (t: ScrapeTask) => void
|
||||
onCancel: (t: ScrapeTask) => void
|
||||
onDelete: (t: ScrapeTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Matched Poster / Info Banner */}
|
||||
{task.matched_title ? (
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
|
||||
已匹配
|
||||
</span>
|
||||
{task.provider && (
|
||||
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
|
||||
{task.matched_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
|
||||
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
|
||||
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
|
||||
</div>
|
||||
{task.media_id && (
|
||||
<Link
|
||||
to={`/media/${task.media_id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
|
||||
>
|
||||
<span>在媒体详情中查看</span>
|
||||
<ExternalLink size={11} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Media Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">原始媒体标题</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">所属媒体库</span>
|
||||
<span className="font-medium text-ink-100">{task.library_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">媒体库类型</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{TYPE_LABELS[task.media_type] ?? task.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{task.episode_images ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>磁盘文件路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.media_path, '文件路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.media_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error, '错误日志')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>入队时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新刮削
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { FFToolsPanel } from '../components/FFToolsPanel'
|
||||
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||
import { LibrarySettingsPanel } from './LibrarySettingsPanel'
|
||||
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
@@ -168,10 +169,11 @@ export function SettingsPage() {
|
||||
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{group.key === 'library' && <LibrarySettingsPanel />}
|
||||
{group.key === 'database' && <DatabaseSettingsPanel />}
|
||||
{group.key === 'api-configs' && <APIConfigsPanel />}
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
|
||||
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user