Compare commits

..

46 Commits

Author SHA1 Message Date
truewhile 0c4668c156 优化 2026-09-19 21:30:32 +08:00
truewhile 474513f18a bug处理 2026-09-18 23:35:56 +08:00
truewhile 0810bfb234 bug处理 2026-09-18 22:52:09 +08:00
truewhile 61a7260eaf 优化,现有bug处理 2026-09-18 21:55:12 +08:00
truewhile 45f4763b0c 添加metatube部署文件 2026-09-18 12:00:04 +08:00
truewhile f4f57759ce 优化移动端播放 2026-09-18 11:28:33 +08:00
truewhile 6ec9adc976 优化移动端播放 2026-09-18 10:09:08 +08:00
truewhile f4abdf78e2 优化在线字幕 2026-09-17 23:53:22 +08:00
truewhile ae8db26c1e bug处理 2026-09-17 23:20:58 +08:00
truewhile 8017e994f4 优化 2026-09-17 22:57:41 +08:00
truewhile d56fb53a08 优化界面,优化多版本切换逻辑 2026-09-17 22:33:53 +08:00
truewhile 30ca94c79b 优化问题 2026-09-17 14:17:09 +08:00
truewhile 9c1f9a6e7e bug处理 2026-09-17 11:19:38 +08:00
truewhile ec7be38266 优化vr播放逻辑 2026-09-17 09:22:00 +08:00
truewhile ebf791c436 优化 2026-09-17 01:29:10 +08:00
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
truewhile 57ee482146 优化 2026-09-15 23:13:31 +08:00
truewhile a9b3110b2c 优化 2026-09-15 22:05:34 +08:00
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
truewhile c911a57467 bug处理 2026-09-15 09:58:47 +08:00
truewhile 1dbc3f8fe7 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-15 00:00:54 +08:00
truewhile a563ee11d6 优化,添加滚动记忆 2026-09-15 00:00:35 +08:00
truewhile 215ebf6942 Merge pull request #33 from truewhile/cursor/fix-library-scrape-dialog-position
优化
2026-09-14 22:55:16 +08:00
truewhile e4c0e85eaa 优化 2026-09-14 22:54:09 +08:00
truewhile 60f05129c9 优化 2026-09-14 21:56:47 +08:00
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
truewhile 1ec8303fec bug处理 2026-09-14 21:16:34 +08:00
truewhile b28c0d9247 添加115云端解码功能 2026-09-14 17:57:51 +08:00
truewhile 91c6d9dfd9 处理token刷新失败bug 2026-09-14 16:47:44 +08:00
truewhile 95d8126d65 优化windows包,优化windos可执行文件功能 2026-09-14 15:48:59 +08:00
truewhile 3aa8662c45 优化,日志脱敏 2026-09-14 15:12:42 +08:00
206 changed files with 18055 additions and 2995 deletions
+2
View File
@@ -0,0 +1,2 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+6 -2
View File
@@ -115,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
@@ -196,8 +196,12 @@ jobs:
path: web/dist
- name: Build binary
run: |
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
LDFLAGS="$LDFLAGS -H=windowsgui"
fi
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
go build -trimpath -ldflags="$LDFLAGS" \
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
# before the Go toolchain touches the web package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -77,7 +77,7 @@ jobs:
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -130,4 +130,4 @@ jobs:
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -51,7 +51,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
+1
View File
@@ -0,0 +1 @@
20.19.0
+2 -2
View File
@@ -2,7 +2,7 @@
# =============================================================================
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
@@ -15,7 +15,7 @@
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
WORKDIR /app/web
COPY web/package*.json ./
+14
View File
@@ -199,6 +199,11 @@ environment:
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**日志保留时间太短?**
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
@@ -207,6 +212,7 @@ environment:
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
前端构建要求 Node.js `20.19+` 或 `22.12+`。
```bash
npm --prefix web ci
@@ -219,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
Windows 本地打包:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
---
## 鸣谢
+8
View File
@@ -199,6 +199,14 @@ npm --prefix web run dev
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
Build the Windows executable locally:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
---
## Acknowledgements
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// application owns the long-running MeBox server and all resources created
// during startup. Platform entry points decide how the application is
// controlled: a console signal loop or a Windows notification-area icon.
type application struct {
cfg *config.Config
logger *zap.Logger
embyCompatLogger *zap.Logger
serverManager *serverManager
services *service.Container
closeMu sync.Mutex
closeFuncs []func()
shutdown sync.Once
shutdownErr error
}
func newApplication() (*application, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config load failed: %w", err)
}
logger, closeLogger, err := newLoggerWithCloser(cfg)
if err != nil {
return nil, fmt.Errorf("logger init failed: %w", err)
}
app := &application{
cfg: cfg,
logger: logger,
closeFuncs: []func(){closeLogger},
}
if err := app.start(); err != nil {
app.closeLoggers()
return nil, err
}
return app, nil
}
func (a *application) start() error {
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
if err != nil {
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
}
a.embyCompatLogger = embyCompatLogger
a.addCloser(closeEmbyCompatLogger)
appVersion := effectiveVersion(version)
a.logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", a.cfg.App.Port),
zap.String("data_dir", a.cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
)
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
return fmt.Errorf("create dir %s failed: %w", dir, err)
}
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
return fmt.Errorf("database open failed: %w", err)
}
if err := waitForDatabase(db, a.logger); err != nil {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
applyCPUThreadLimit(a.cfg, a.logger)
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
a.serverManager = newServerManager(a.cfg, a.logger, router)
a.services.ReloadHTTPServer = a.serverManager.Reload
if err := a.serverManager.Start(); err != nil {
a.logger.Error("listen failed", zap.Error(err))
return fmt.Errorf("listen failed: %w", err)
}
go func() {
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
a.logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
)
}
}()
helper.Go(a.logger, "services.boot", a.services.Boot)
return nil
}
// Shutdown is idempotent and safe to call from both the tray handler and the
// systray exit callback.
func (a *application) Shutdown() error {
a.shutdown.Do(func() {
a.logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if a.serverManager != nil {
if err := a.serverManager.Shutdown(ctx); err != nil {
a.shutdownErr = err
a.logger.Error("graceful shutdown failed", zap.Error(err))
}
}
cancel()
if a.services != nil {
a.services.Close()
}
a.logger.Info("MeBox stopped")
_ = a.logger.Sync()
if a.embyCompatLogger != nil {
_ = a.embyCompatLogger.Sync()
}
a.closeLoggers()
})
return a.shutdownErr
}
func (a *application) addCloser(fn func()) {
if fn == nil {
return
}
a.closeMu.Lock()
a.closeFuncs = append(a.closeFuncs, fn)
a.closeMu.Unlock()
}
func (a *application) closeLoggers() {
a.closeMu.Lock()
closers := append([]func(){}, a.closeFuncs...)
a.closeFuncs = nil
a.closeMu.Unlock()
for i := len(closers) - 1; i >= 0; i-- {
closers[i]()
}
}
func (a *application) localURL() string {
config.RuntimeMu.RLock()
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
port := a.cfg.App.Port
config.RuntimeMu.RUnlock()
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
}
+1 -114
View File
@@ -11,21 +11,8 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
@@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string {
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
os.Exit(1)
}
logger, err := newLogger(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
os.Exit(1)
}
defer func() { _ = logger.Sync() }()
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
if err != nil {
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
}
defer func() {
_ = embyCompatLogger.Sync()
closeEmbyCompatLogger()
}()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
)
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
db, err := database.Open(cfg, logger)
if err != nil {
logger.Fatal("database open failed", zap.Error(err))
}
if err := waitForDatabase(db, logger); err != nil {
logger.Fatal("database not ready", zap.Error(err))
}
if err := database.AutoMigrate(db); err != nil {
logger.Fatal("auto-migrate failed", zap.Error(err))
}
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
applyCPUThreadLimit(cfg, logger)
services := service.NewWithVersion(cfg, logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, embyCompatLogger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
if err := serverMgr.Start(); err != nil {
logger.Fatal("listen failed", zap.Error(err))
}
go func() {
scheme := "http"
if cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
)
}
}()
helper.Go(logger, "services.boot", services.Boot)
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := serverMgr.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MeBox stopped")
runProgram()
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func runProgram() {
app, err := newApplication()
if err != nil {
reportError("MeBox 启动失败", err)
os.Exit(1)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
if err := app.Shutdown(); err != nil {
reportError("MeBox 退出失败", err)
}
}
func reportError(title string, err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
}
+330
View File
@@ -0,0 +1,330 @@
//go:build windows
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"fyne.io/systray"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/truewhile/MeBox/internal/brand"
)
const (
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
runRegistryValue = "MeBox"
singleInstanceName = `Local\MeBox-Server`
)
var errAlreadyRunning = errors.New("MeBox is already running")
func runProgram() {
if err := prepareWorkingDirectory(); err != nil {
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
return
}
instance, err := acquireSingleInstance()
if errors.Is(err, errAlreadyRunning) {
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
return
}
if err != nil {
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
return
}
app, err := newApplication()
if err != nil {
_ = instance.Close()
reportError("MeBox 启动失败", err)
return
}
controller := &trayController{app: app}
systray.Run(controller.onReady, controller.onExit)
// Release the mutex before starting the replacement process. The new
// process must be able to acquire it immediately after the old one exits.
_ = instance.Close()
if !controller.readyClosed.Load() {
_ = app.Shutdown()
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
return
}
if controller.restartRequested.Load() {
if err := launchSelf(); err != nil {
reportError("MeBox 重启失败", err)
}
}
}
type trayController struct {
app *application
readyClosed atomic.Bool
restartRequested atomic.Bool
shutdownStarted atomic.Bool
}
func (c *trayController) onReady() {
defer func() {
c.readyClosed.Store(true)
}()
systray.SetIcon(brand.Icon)
systray.SetTooltip("MeBox")
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
systray.AddSeparator()
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
initialAutoStart := mAutoStart.Checked()
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openURL(c.app.localURL()); err != nil {
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
}
case <-mAutoStart.ClickedCh:
enable := !mAutoStart.Checked()
if err := setAutoStart(enable); err != nil {
if initialAutoStart {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
continue
}
if enable {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
initialAutoStart = enable
case <-mLogs.ClickedCh:
if err := c.app.openLog(); err != nil {
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
}
case <-mRestart.ClickedCh:
c.restart()
case <-mQuit.ClickedCh:
c.quit()
}
}
}()
}
func (c *trayController) onExit() {
_ = c.app.Shutdown()
}
func (c *trayController) quit() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (c *trayController) restart() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
c.restartRequested.Store(true)
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (a *application) openLog() error {
appLog, _, _ := logFilePaths(a.cfg)
if appLog != "" {
if _, err := os.Stat(appLog); err == nil {
return openPath(appLog)
}
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
return openPath(filepath.Dir(appLog))
}
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o750); err != nil {
return err
}
return openPath(logDir)
}
func prepareWorkingDirectory() error {
exe, err := os.Executable()
if err != nil {
return err
}
exeDir := filepath.Dir(exe)
cwd, err := os.Getwd()
if err != nil {
return err
}
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
return nil
}
return os.Chdir(exeDir)
}
func looksLikeProjectDirectory(dir string) bool {
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
func samePath(left, right string) bool {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
type singleInstance struct {
handle windows.Handle
}
func acquireSingleInstance() (*singleInstance, error) {
name, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return nil, err
}
handle, err := windows.CreateMutex(nil, false, name)
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
return nil, errAlreadyRunning
}
if err != nil {
return nil, err
}
return &singleInstance{handle: handle}, nil
}
func (s *singleInstance) Close() error {
if s == nil || s.handle == 0 {
return nil
}
err := windows.CloseHandle(s.handle)
s.handle = 0
return err
}
func launchSelf() error {
exe, err := os.Executable()
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Dir = cwd
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
func autoStartEnabled() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
value, _, err := key.GetStringValue(runRegistryValue)
if err != nil {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
}
func setAutoStart(enabled bool) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
if err != nil {
return err
}
defer key.Close()
if !enabled {
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
}
func openURL(url string) error {
return shellOpen(url)
}
func openPath(path string) error {
return shellOpen(path)
}
func shellOpen(target string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
verbPtr, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
}
func reportError(title string, err error) {
if err == nil {
return
}
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
if textErr != nil {
return
}
caption, captionErr := windows.UTF16PtrFromString("MeBox")
if captionErr != nil {
return
}
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package main
// Regenerate the linked Windows resources after changing the project logo or
// manifest:
//
// go generate ./cmd/server
//
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
Binary file not shown.
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="MeBox"
type="win32"
/>
<description>MeBox media server</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
+216
View File
@@ -0,0 +1,216 @@
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
#
# 组件:
# MeBox + PostgreSQL + Redis + MetaTube
#
# 与 docker-compose.standard.yml 的区别:
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.metatube.yml up -d
#
# 首次启动后需要做一次配置(只有这一处是手动的):
# 登录 MeBox → 设置 → 番号刮削:
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
# - MetaTube 服务端地址:http://metatube:8080
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
#
# 说明:
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
# - 更新全部组件:
# docker compose -f docker-compose.metatube.yml pull
# docker compose -f docker-compose.metatube.yml up -d
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
#
# 默认账号:
# admin / admin123
services:
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mebox。
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# 一次性容器:确保 metatube 库存在。
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
# 数据库已存在时它会直接退出,重复执行无副作用。
metatube-db-init:
image: postgres:16-alpine
pull_policy: missing
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: mebox
entrypoint: ["/bin/sh", "-c"]
command:
- |
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
metatube:
image: ghcr.io/metatube-community/metatube-server:latest
restart: unless-stopped
depends_on:
metatube-db-init:
condition: service_completed_successfully
environment:
TZ: Asia/Shanghai
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
HTTP_PROXY: ""
HTTPS_PROXY: ""
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
command:
- -dsn
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
- -port
- "8080"
- -db-auto-migrate
- -db-prepared-stmt
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
+10 -10
View File
@@ -81,8 +81,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -124,8 +124,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -147,8 +147,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -172,8 +172,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
opensearch:
image: opensearchproject/opensearch:2
@@ -196,5 +196,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+4 -4
View File
@@ -64,8 +64,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
@@ -82,5 +82,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+11 -28
View File
@@ -44,30 +44,13 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
@@ -76,8 +59,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -113,8 +96,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -136,8 +119,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -161,5 +144,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+6 -6
View File
@@ -92,8 +92,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
@@ -141,8 +141,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -166,5 +166,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+743
View File
@@ -0,0 +1,743 @@
# 阅读模块(Reading Module)设计与实施方案
> 状态:设计稿,待评审
> 目标版本:v0.2.0(分期落地,见第 9 节)
> 关联现有子系统:媒体库 / 网盘存储 / 权限体系 / 任务队列
---
## 1. 目标与范围
### 1.1 已确认的产品决策
| 维度 | 决策 |
| --- | --- |
| 内容类型 | **电子书 + 漫画统一书架**(EPUB / TXT / PDF / MOBI 与 CBZ / CBR / 图片文件夹) |
| 书源 | **独立书库**(不复用影视媒体库)+ **网盘直链阅读** |
| 首版范围 | **完整版**:多用户书库权限 + 阅读统计 |
| 阅读形态 | **滚动流式与分页翻页双模式**,用户可切换并持久化偏好 |
### 1.2 明确的非目标
- **不接入 Emby/Jellyfin 协议。** Emby 的 `Items` / `Views` / `PlaybackInfo` 语义围绕音视频构建,没有书籍章节与阅读进度的对应概念。强行映射会污染 `internal/service/emby_*.go` 与 `internal/handler/emby_*.go` 的既有兼容层,收益极低。阅读能力只通过 MeBox 自己的 Web UI 提供。
- **不复用 `model.Library` / `LibraryRoot`。** `Library.Type` 的取值域是 `movie/tv/anime/music`,且被海报墙轮播(`CarouselEnabled`)、自动整理管线、Emby 视图、首页预览等链路消费。把书库塞进去会导致这些链路需要到处加 `type != "book"` 判断。
- 首版不做:听书 TTS、在线书源(笔趣阁类)、社交分享、跨设备同步批注冲突合并。
---
## 2. 总体架构
### 2.1 分层落位
完全沿用现有分层,不引入新模式:
```
web/src/pages/Books*.tsx ← 页面
web/src/components/Book*.tsx ← 阅读器与书架组件
web/src/api/books.ts ← axios 封装(仿 web/src/api/library.ts)
↓ /api/books/*
internal/handler/books*.go ← 反序列化 + 权限校验 + 响应
internal/service/book_*.go ← 业务策略(扫描、解析、进度、统计)
internal/repository/book_*.go ← 纯持久化
internal/model/book.go ← GORM 模型,注册进 model.AllModels()
```
新增路由注册走 `internal/handler/routes_authenticated_features.go` 的既有范式,新增一个 `registerAuthedBookRoutes(authed, svc)`,在 `registerAuthenticatedRoutes` 链上挂载。`service.Container` 与 `repository.Container` 各追加一个字段。
### 2.2 与现有能力的复用点
| 现有部件 | 复用方式 |
| --- | --- |
| `service.StreamService.ServeFile`(`internal/service/stream_file.go`) | 已用 `http.ServeContent` 处理 HEAD / Range / If-Modified-Since,**PDF 与原始文件流直接照搬这条路径** |
| `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 |
| `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** |
| `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 |
| `service.PruneImageCache`(`internal/service/cache_cleanup.go`) | 现成的「按总大小做 LRU 淘汰」助手,书籍缓存淘汰直接复用它 |
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `MaxDiskUsageMB` / `TTLHours` / `AutoCleanup` / `CleanupIntervalMin`,书籍缓存容量配置直接挂进去 |
| `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` |
| `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) |
| `model.UserPermission` | 新增阅读权限位,见第 7 节 |
| `helper.Go` / `Container.stopCtx` | 后台扫描任务的生命周期管理 |
---
## 3. 数据模型
新增文件 `internal/model/book.go`,并在 `internal/model/model.go` 的 `AllModels()` 中追加。所有表继承 `model.Base`(UUID 主键 + 时间戳 + 软删除)。
**表名约定**:`internal/model` 全包**没有任何 `TableName()` 覆盖**,一律使用 GORM 默认复数化(例如 `PlaybackHistory` → `playback_histories`,可从 `internal/database/schema_migration.go` 的裸 SQL 印证)。新表沿用该约定,不引入例外。因此模型命名要保证复数化结果干净:
| 模型 | 表名 |
| --- | --- |
| `Book` | `books` |
| `BookLibrary` | `book_libraries` |
| `BookSource` | `book_sources` |
| `BookChapter` | `book_chapters` |
| `BookProgress` | `book_progresses` |
| `BookAnnotation` | `book_annotations` |
| `BookFavorite` | `book_favorites` |
| `BookReadingSession` | `book_reading_sessions` |
| `BookDailyStat` | `book_daily_stats` |
(刻意用 `BookDailyStat` 而不是 `BookStatDaily`——后者复数化会得到 `book_stat_dailies`。)
### 3.1 书库与书源
```go
// BookLibrary 是独立于影视媒体库的书库。
type BookLibrary struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Kind string `gorm:"size:16;not null;default:mixed" json:"kind"` // ebook / comic / mixed
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"`
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
ScanStatus string `gorm:"size:16;default:idle" json:"scan_status"` // idle / scanning / error
ScanMessage string `gorm:"size:512" json:"scan_message,omitempty"`
}
// BookSource 是书库下的一条挂载来源:本地目录或网盘路径。
type BookSource struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
Name string `gorm:"size:128" json:"name,omitempty"`
StorageKind string `gorm:"size:16;not null;default:local" json:"storage_kind"` // local / cloud
Path string `gorm:"size:1024;not null" json:"path"` // 本地绝对路径 / 网盘内路径
StorageConfigID string `gorm:"index;size:36" json:"storage_config_id,omitempty"` // 复用 model.StorageConfig
Depth int `gorm:"default:3" json:"depth"` // 扫描递归深度上限
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
```
`StorageKind = cloud` 时,`StorageConfigID` 指向一条 `StorageConfig`(`Type` ∈ `cloud115 / clouddrive2 / openlist / emby_remote`)。凭据解密沿用 `service.CryptoService`。
### 3.2 书籍与章节
```go
type Book struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
SourceID string `gorm:"uniqueIndex:uniq_book_source_path,priority:1;index;size:36;not null" json:"source_id"`
// SourcePath 在本地源是绝对路径,在网盘源是「网盘内路径」,两者都用
// (source_id, source_path) 做唯一键,天然隔离两个 ID 空间。
SourcePath string `gorm:"uniqueIndex:uniq_book_source_path,priority:2;size:1024;not null" json:"source_path"`
SourceRef string `gorm:"size:256" json:"source_ref,omitempty"` // 网盘 file id / pickcode
Title string `gorm:"size:512;not null" json:"title"`
Author string `gorm:"size:256;index" json:"author,omitempty"`
SeriesName string `gorm:"size:256;index" json:"series_name,omitempty"`
Volume int `json:"volume"`
Format string `gorm:"size:16;not null" json:"format"` // epub/txt/pdf/mobi/cbz/cbr/folder
MediaKind string `gorm:"size:16;not null;default:ebook" json:"media_kind"` // ebook / comic
SizeBytes int64 `json:"size_bytes"`
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` // 大小+首尾采样,去重
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Description string `gorm:"type:text" json:"description,omitempty"`
Language string `gorm:"size:32" json:"language,omitempty"`
Tags string `gorm:"type:text" json:"tags,omitempty"` // 逗号分隔
ChapterCount int `json:"chapter_count"`
WordCount int64 `json:"word_count"`
PageCount int `json:"page_count"` // 漫画总页数 / PDF 页数
ParseStatus string `gorm:"size:16;default:pending" json:"parse_status"` // pending/ok/failed
ParseMessage string `gorm:"size:512" json:"parse_message,omitempty"`
NSFW bool `gorm:"default:false" json:"nsfw"`
AddedAt time.Time `json:"added_at"`
}
```
**唯一键说明**:`SourcePath` 上的 `uniqueIndex` 需与 `SourceID` 组成复合键(`uniq_book_source_path`,priority 1 = `source_id`)。同一本书被两个书源包含时允许重复入库,这是符合预期的(用户可能故意如此)。
```go
// BookChapter 只存索引,不存正文(见 3.4 的取舍)。
type BookChapter struct {
Base
BookID string `gorm:"index:idx_book_chapter,priority:1;size:36;not null" json:"book_id"`
Index int `gorm:"index:idx_book_chapter,priority:2" json:"index"`
Title string `gorm:"size:512" json:"title"`
Level int `gorm:"default:1" json:"level"` // 目录嵌套层级,1 = 顶级
// 电子书定位:二选一
Href string `gorm:"size:1024" json:"href,omitempty"` // EPUB zip 内条目路径
StartOffset int64 `json:"start_offset"` // TXT 字节区间
EndOffset int64 `json:"end_offset"`
// 漫画/PDF 定位
PageStart int `json:"page_start"`
PageEnd int `json:"page_end"`
CharCount int `json:"char_count"`
}
```
### 3.3 进度、批注、收藏、统计
```go
// BookProgress 每个用户每本书一行(复合唯一键,仿 model.PlaybackHistory 的 uniq_user_history 模式)。
type BookProgress struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `gorm:"default:0" json:"chapter_index"`
ChapterTitle string `gorm:"size:512" json:"chapter_title,omitempty"`
CharOffset int `json:"char_offset"` // 章内字符偏移(电子书)
PageIndex int `json:"page_index"` // 页码(漫画 / PDF)
Percent float64 `json:"percent"` // 全书百分比,书架进度条展示用
ScrollRatio float64 `json:"scroll_ratio"` // 章内滚动比例,跨端还原更精确
ReaderMode string `gorm:"size:16;default:scroll" json:"reader_mode"` // scroll / paged
Finished bool `gorm:"default:false" json:"finished"`
TotalSeconds int64 `json:"total_seconds"`
LastReadAt time.Time `gorm:"index" json:"last_read_at"`
}
type BookAnnotation struct {
Base
UserID string `gorm:"index:idx_book_anno,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index:idx_book_anno,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `json:"chapter_index"`
Type string `gorm:"size:16;not null" json:"type"` // bookmark / highlight / note
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
SelectedText string `gorm:"size:2048" json:"selected_text,omitempty"`
Note string `gorm:"type:text" json:"note,omitempty"`
Color string `gorm:"size:16" json:"color,omitempty"`
}
type BookFavorite struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:2;size:36;not null" json:"book_id"`
}
// BookReadingSession 由前端心跳驱动,服务端按小时聚合,避免行数爆炸。
type BookReadingSession struct {
Base
UserID string `gorm:"index:idx_book_stat,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index;size:36;not null" json:"book_id"`
BucketStart time.Time `gorm:"index:idx_book_stat,priority:2" json:"bucket_start"` // 截断到小时
Seconds int64 `json:"seconds"`
CharsRead int64 `json:"chars_read"`
PagesRead int `json:"pages_read"`
}
// BookDailyStat 每日汇总,供热力图与「年度阅读报告」查询,避免实时扫 session 表。
type BookDailyStat struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_daily,priority:1;size:36;not null" json:"user_id"`
Day string `gorm:"uniqueIndex:uniq_user_book_daily,priority:2;size:10;not null" json:"day"` // YYYY-MM-DD
Seconds int64 `json:"seconds"`
Chars int64 `json:"chars"`
Pages int `json:"pages"`
Books int `json:"books"` // 当日有阅读记录的书数
}
```
### 3.4 关键取舍:正文不入库
**决策:DB 只存章节索引(偏移量 / zip 内路径 / 页码区间),正文按需从源文件读取。**
理由:
1. 网文 TXT 常见 5–50MB,漫画单册 100–800MB。入库会让 SQLite 单文件膨胀到数十 GB,直接冲击 `docker-compose.simple.yml` 的「单文件数据库好备份」定位,也会拖慢全库 VACUUM / 备份 / 数据库迁移(`internal/service/database_admin.go`)。
2. 源文件本来就是权威副本,重复存储没有收益。
3. EPUB 与 CBZ 本质上都是 zip,**随机读取 zip 内单个条目成本极低**(读中央目录 + 解压目标条目),不需要把整本解压落盘。
代价是每次打开章节都要读源文件。缓解手段:
- 本地源:`os.Open` + `io.SectionReader`,代价可忽略。
- 网盘源:见 4.3 的本地缓存策略,且对已缓存的章节走本地。
### 3.5 用户级字段(挂在 `model.User` 上)
沿用 `AllowedLibraryIDs` 的 JSON-in-text 模式(见 `internal/model/user.go`),**不复用影视库字段**,避免两个 ID 空间交叉:
```go
// 追加到 model.User
ReaderSettings string `gorm:"type:text" json:"-"` // 阅读器偏好 JSON
AllowedBookLibraryIDs string `gorm:"type:text" json:"-"` // 空 = 不限制
AllowedBookLibraryList []string `gorm:"-" json:"allowed_book_library_ids,omitempty"`
```
`ReaderSettings` 结构(前端读写,服务端仅透传与长度校验):
```json
{
"mode": "scroll|paged",
"fontSize": 18,
"lineHeight": 1.8,
"fontFamily": "serif|sans|custom",
"contentWidth": 720,
"theme": "light|sepia|dark|black",
"pageAnimation": "slide|fade|none",
"comicLayout": "single|double|auto",
"comicDirection": "ltr|rtl",
"hideScrollbar": true
}
```
放在 `User` 行内(而非新表)的理由:与 `PlayerVolume` / `DanmakuFontSize` 等既有播放器偏好一致,读取时随用户信息一并返回,无需额外查询。
---
## 4. 书源与内容读取管线
### 4.1 扫描流程
```
POST /api/books/libraries/:id/scan
→ BookScannerService.ScanLibrary(ctx, libraryID)
1. 置 ScanStatus=scanning,通过 SSEHub 广播进度(复用 service.SSEHub)
2. 遍历启用的 BookSource
- local: filepath.WalkDir,按扩展名白名单过滤,超过 Depth 停止递归
- cloud: cloud.New(cfg.Type, cfg, client).List(ctx, dirID) 递归列目录
3. 对每个候选文件调 BookParser.ParseMeta(reader) 拿元信息 + 目录
4. Upsert 到 books / book_chapters(source_id + source_path 为幂等键)
5. 源上已消失的书标记软删除(与影视库扫描语义保持一致)
6. 置 ScanStatus=idle,记录 LastScanAt
```
扩展名白名单:`.epub .txt .pdf .mobi .azw3 .cbz .cbr .zip .rar`(`.zip/.rar` 仅当目录内全是图片时按漫画处理,否则跳过,防止误吞压缩包)。
并发:复用 `internal/service` 现有的 worker 池写法,默认 2–4 并发解析(解析要读文件,IO 密集)。
### 4.2 各格式解析策略
| 格式 | 元信息 | 章节 / 页 | 正文读取 |
| --- | --- | --- | --- |
| **EPUB** | zip → `META-INF/container.xml` → OPF → `dc:title/dc:creator/dc:language/dc:description`;封面取 OPF `meta[name=cover]` 指向项,退化到 `guide` | 按 spine 顺序,标题取每个 XHTML 的 `<title>` 或首个 `h1..h3`;`Level` 由 nav/ncx 的嵌套深度推断 | `archive/zip` 定位 `Href` 条目,读出 XHTML → 服务端清洗后返回 |
| **TXT** | 文件名(`书名 - 作者.txt` 模式)+ 编码探测 | 正则切分:`第[一二三四五六七八九十百千零两0-9]+[章节卷回篇]`、`Chapter\s+\d+`、`^\s*\d+\s*$`;命中不足 3 个则按固定字节窗口切片 | `io.SectionReader` 读 `[StartOffset, EndOffset)` → 按探测到的编码转 UTF-8 |
| **PDF** | 首页/元数据(页数、标题);封面渲染首屏,失败则留空 | 单章「正文」,`PageStart/PageEnd` = 全书页 | 原始文件流(Range),前端 pdf.js 自己解析 |
| **CBZ / CBR** | zip/rar 条目自然排序,第一张图做封面 | 单章,页区间 = 图片条目序号 | 按页解压单条目,走图片响应路径 |
| **图片文件夹** | 目录名 | 单章,页区间 = 排序后图片序号 | 直接读本地文件 |
| **MOBI / AZW3** | PalmDOC / KF8 头 | 首版**只入库展示、不支持在线阅读**,详情页给出「下载原文件」入口 | — |
实现细节提示:
- 编码探测用 `golang.org/x/text`(已是 `go.mod` 间接依赖)。GBK/Big5/UTF-16LE 都要覆盖,中文网文 TXT 大量是 GBK。
- CBR 需要 RAR 解压。建议引入纯 Go 的 `github.com/nwaples/rardecode`;若不接受新依赖,首版把 CBR 归入「只入库、不可读」。
- EPUB XHTML 清洗**必须在服务端做**:剔除 `<script>`、`on*` 事件属性、`<iframe>`、外部 `http(s)` 资源引用,把 `src/href` 重写为 `/api/books/:id/res/*`。前端再叠一层 DOMPurify 作为纵深防御。
### 4.3 网盘书籍的读取策略
网盘直链的核心约束:**EPUB / CBZ 的解析必须能读到文件尾部**(zip 中央目录在末尾),但 `cloud.Provider.Resolve` 返回的是短时效 URL,且 115 直链依赖 UA/Cookie(`DirectLink.Headers`),浏览器无法直接携带。
因此分两条路径:
**A. 解析阶段 —— 完整拉取到缓存目录**
```
<CacheDir>/books/<sourceID>/<hash>.<ext>
```
`BookParser` 通过 `DirectLink` 拉全量文件到缓存后再解析。缓存目录复用 `config.CacheConfig.CacheDir`(默认 `<DataDir>/cache`,容器里是 `/cache`),容量上限新加一项 `CacheConfig.BooksMaxSizeMB`(默认 2GB),走 LRU 淘汰。缓存命中的书后续正文读取也直接走本地,不再回网盘。
**B. 阅读阶段 —— 优先本地缓存,未命中走代理流**
未缓存时由服务端反代目标 URL(`DirectLink.Proxy=true` 时同样反代),并把 `Content-Type: image/*` 或 `application/pdf` 透传给前端。反代实现直接参照 `internal/service/cloud115_hls_proxy.go` 的响应头透传白名单(`Content-Type/Content-Length/Content-Range/Accept-Ranges/ETag/Last-Modified`)。
**C. 阅读进度与文件解耦** —— 代码里区分「源」「位置」:
```go
type BookLocator struct {
Kind string `json:"kind"` // local / cloud
LocalPath string `json:"local_path,omitempty"`
CloudConfig string `json:"cloud_config,omitempty"`
CloudRef string `json:"cloud_ref,omitempty"`
Href string `json:"href,omitempty"` // zip 内条目
StartOffset int64 `json:"start_offset,omitempty"`
EndOffset int64 `json:"end_offset,omitempty"`
}
```
被缓存的书 `Kind` 仍报 `cloud`(进度不绑物理位置),这样缓存被淘汰后进度依然有效。这是不把 `Book.Path` 直接存成本地缓存路径的原因。
### 4.4 磁盘与容器
书籍目录需要在 compose 里挂载,并在 README 的部署档位表补充说明。新缓存目录复用现有 `MEBOX_CACHE_CACHE_DIR`(`docker-compose.simple.yml` 中为 `/cache`),无需新增环境变量。
---
## 5. HTTP API 设计
全部挂在 `/api/books/*`,注册在 `registerAuthedBookRoutes`。响应统一走 `internal/handler/response.go` 的既有助手。
### 5.1 书库与扫描(管理端)
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/books/libraries` | `can_read_books` | 列表,按 `AllowedBookLibraryIDs` 过滤可见性 |
| POST | `/api/books/libraries` | `can_manage_book_library` | 新建/更新书库 |
| DELETE | `/api/books/libraries/:id` | `can_manage_book_library` | 删除(含级联软删 books) |
| GET | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 书源列表 |
| POST | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 新增书源(本地目录 / 网盘路径) |
| POST | `/api/books/libraries/:id/scan` | `can_manage_book_library` | 触发扫描,返回 task id |
| GET | `/api/books/scan/status` | `can_manage_book_library` | 扫描进度轮询 |
| GET | `/api/books/browse` | `can_manage_book_library` | 网盘路径浏览(复用 cloud Provider.List) |
### 5.2 书架与详情
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books` | 书架列表。参数:`library_id`、`keyword`、`media_kind`、`format`、`tag`、`sort`(`title/added_at/last_read/progress`)、`page/page_size` |
| GET | `/api/books/continue-reading` | 最近在读,首页「继续阅读」区块用 |
| GET | `/api/books/:id` | 详情(元信息 + 目录树 + 当前用户进度 + 收藏态) |
| GET | `/api/books/:id/cover` | 封面。走 `ImageProxy` 的缓存与缩放,参数 `w` |
| GET | `/api/books/:id/chapters/:index` | 章节正文。电子书返回 `text/html`(已清洗)或 `application/json` 结构化段落 |
| GET | `/api/books/:id/res/*path` | EPUB 内部资源(图片/字体/CSS),路径参数为 zip 内条目 |
| GET | `/api/books/:id/pages/:index` | 漫画单页图片,`Content-Type: image/*` + 长效缓存头 |
| GET | `/api/books/:id/file` | 原始文件流(Range),pdf.js 与「下载原文件」共用 |
| POST | `/api/books/:id/favorite` | 收藏 / 取消收藏 |
| DELETE | `/api/books/:id` | 删除(`can_manage_books`) |
**章节响应格式(推荐 JSON 而非裸 HTML)**:
```json
{
"index": 12,
"title": "第十二章 雨夜",
"char_count": 3820,
"blocks": [
{ "type": "p", "text": "……" },
{ "type": "img", "src": "/api/books/xxx/res/images/1.png" }
],
"next_index": 13,
"prev_index": 11
}
```
用结构化 blocks 而非 HTML 的理由:
1. 前端可安全渲染,不必 `dangerouslySetInnerHTML`,彻底绕开 XSS 面。
2. 分页模式需要按节点测量高度做分栏,结构化的段落数组比操作 DOM 简单得多。
3. 字号/行距/主题切换只需重渲染,不碰 HTML 字符串。
保底方案:`?format=html` 仍返回清洗后的 HTML,供 EPUB 中复杂排版(表格、脚注、双向文字)回退。
### 5.3 进度、批注、统计
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books/:id/progress` | 当前用户进度 |
| PUT | `/api/books/:id/progress` | 保存进度。前端**节流 5s + 页面卸载时 `sendBeacon`** |
| GET | `/api/books/:id/annotations` | 批注列表 |
| POST | `/api/books/:id/annotations` | 新建 |
| PATCH | `/api/books/:id/annotations/:aid` | 修改 note / color |
| DELETE | `/api/books/:id/annotations/:aid` | 删除 |
| POST | `/api/books/:id/heartbeat` | 阅读心跳,body 为 `{seconds, chars, pages}`,落 `book_reading_sessions` 小时桶 |
| GET | `/api/books/reader-settings` | 读取当前用户的阅读器偏好(`model.User.ReaderSettings`) |
| PUT | `/api/books/reader-settings` | 保存阅读器偏好(服务端只做长度与枚举校验后原样存储) |
| GET | `/api/books/stats` | 个人统计:累计时长、已读书数、在读、近 30 天热力图 |
| GET | `/api/books/stats/overview` | 管理员视角:全站阅读统计(`can_access_settings`) |
进度写入的并发安全:`uniq_user_book` 复合唯一键 + 先 `Updates` 再 `Create` 的 upsert 模式。**参照 `internal/database/schema_migration.go` 里 `dedupePlaybackHistories` 的前车之鉴**——`PlaybackHistory` 曾因 read-then-write 产生重复行导致唯一索引进不去,新表直接写 upsert,不要复制那个 bug。
---
## 6. 前端设计
### 6.1 路由与导航
`web/src/appRoutes.tsx` 新增懒加载路由:
```tsx
const BookshelfPage = lazy(() => import('./pages/BookshelfPage').then(m => ({ default: m.BookshelfPage })))
const BookDetailPage = lazy(() => import('./pages/BookDetailPage').then(m => ({ default: m.BookDetailPage })))
const BookReaderPage = lazy(() => import('./pages/BookReaderPage').then(m => ({ default: m.BookReaderPage })))
const BookStatsPage = lazy(() => import('./pages/BookStatsPage').then(m => ({ default: m.BookStatsPage })))
const BookLibraryPage = lazy(() => import('./pages/BookLibraryPage').then(m => ({ default: m.BookLibraryPage })))
```
```
/books 书架
/books/:id 书籍详情(目录、元信息、开始阅读)
/books/:id/read 阅读器(全屏,隐藏底栏)
/books/stats 阅读统计
/books/library 书库管理(adminOnly)
```
`web/src/components/layoutNavigation.ts` 的改动:
- `MEDIA_NAV_ITEMS` 与 `MOBILE_BOTTOM_NAV_ITEMS` 加「阅读」项(`BookOpen` 图标),普通用户可见。
- 新增 `isBookReaderRoute(pathname)`,并在 `shouldShowMobileBottomNav` 中排除 `/books/:id/read`,与 `isPlayerRoute` 的处理一致。
- `resolveHeaderBack` 补 `/books/...` 的返回链。
- `LAYOUT_NAV_ITEMS` 加「书库管理」条目,`adminOnly: true`。
### 6.2 页面组成
```
web/src/pages/
BookshelfPage.tsx 书架:筛选栏 + 网格/列表双视图 + 继续阅读横滑
BookDetailPage.tsx 详情:封面、元信息、目录树、进度、开始/继续阅读
BookReaderPage.tsx 阅读器外壳:顶栏 + 内容区 + 底部工具条 + 设置抽屉
BookStatsPage.tsx 统计:热力图 + 概览卡片
BookLibraryPage.tsx 书库管理:书库 CRUD + 书源 CRUD + 扫描触发与进度
web/src/components/
book/ReaderCore.tsx 渲染内核分发(按 media_kind + format)
book/ScrollReader.tsx 滚动流式
book/PagedReader.tsx 分页翻页
book/ComicReader.tsx 漫画(单页/双页/右开本/预加载)
book/PdfReader.tsx PDF(pdf.js)
book/ReaderToolbar.tsx 顶栏 + 底栏(章节、进度、目录、批注、设置)
book/ReaderSettingsPanel.tsx 阅读设置
book/ChapterTocDrawer.tsx 目录抽屉
book/AnnotationList.tsx 书签笔记列表
book/ReaderProgressBar.tsx 进度条(可拖拽跳章)
web/src/api/books.ts 接口封装
```
对于 4 类内容,`ReaderCore` 的分发是第一层决策:
| `media_kind` | `format` | 内核 |
| --- | --- | --- |
| ebook | epub / txt | `ScrollReader` 或 `PagedReader`(按 `settings.mode`) |
| ebook | pdf | `PdfReader` |
| comic | cbz / cbr / folder | `ComicReader` |
| ebook | mobi / azw3 | 不提供阅读,仅详情页 |
### 6.3 渲染内核选型(关键决策)
**结论:自研内核,不引入 epub.js / foliate-js。**
对比:
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| `epub.js` | 成熟、CFI 精确定位、多列分页开箱即用 | 维护停滞;CFI 定位难以与自研进度模型(`charOffset` / `percent`)对齐;PDF/漫画仍需另做两个内核 |
| `foliate-js` | 一套 API 覆盖 EPUB/MOBI/CBZ/PDF,排版质量高 | 生态小、文档薄、非稳定 API,需要 vendored 一份并自行承担升级风险 |
| **自研(推荐)** | 进度模型完全可控、跨端一致;零新增重依赖;与既有 Tailwind 主题体系天然统一 | 需要自己实现分页测量、脏 HTML 清洗、资源重写 |
自研方案的可行性依据:分页的本质是「CSS multi-column 布局 + `transform: translateX` 平移」,foliate-js 也是这么做的,核心约 200 行;滚动模式的虚拟化可以直接复用已有的 `react-virtuoso`(已在 `web/package.json`,用于 `VirtualMediaGrid`)。
自研必须做好的三件事:
1. **HTML 清洗**:服务端为主(见 4.2),前端用 `dompurify` 兜底。这是新增的唯一运行时依赖。
2. **资源重写**:EPUB 内部图片/字体/CSS 的 `src`、`href`、`url()` 全部重写到 `/api/books/:id/res/`,否则相对路径会 404。
3. **分页测量与重排**:容器尺寸变化(窗口 resize、字号切换、横竖屏)后必须重新分页,并把「当前段落 + 段内比例」作为锚点恢复位置,不能让用户跳回章首。
### 6.4 双模式实现
**滚动模式(`ScrollReader`)**
- 章内虚拟化:单章文本通常 2k–10k 字,直接整章渲染即可;跨章用「当前章 + 前后各一章」的窗口,滚动到边界时无缝追加。
- 进度:`IntersectionObserver` 观测可视段落,映射为 `charOffset`;`scroll_ratio` 同时上报。
- 优势:移动端体验好,实现简单,长段落无分页误差。
**分页模式(`PagedReader`)**
- 章内:容器设为多列(`column-width: <contentWidth>`),`overflow: hidden`,通过 `translateX` 翻页;总页数由 `scrollWidth / containerWidth` 得出。
- 跨章:翻到本章末尾自动加载下一章首页;反向同理。章首/章尾需处理「残页合并」,避免出现半屏空白页。
- 输入:左右方向键、空格、点击左右热区、滑动手势(移动端)。`comicDirection`/`pageAnimation` 控制方向与动画。
- 进度:`chapter_index` + `page_index` 映射回 `charOffset`。
两种模式共享 `BookProgress`,切换模式时用「章 + 比率」换算,不丢位置。
### 6.5 状态与持久化
- 阅读器设置来自 `authStore` 的用户信息(`ReaderSettings` 反序列化),改动后 `PUT /api/books/reader-settings` 持久化 + 本地 `localStorage` 兜底(首屏渲染不等接口)。
- 进度本地先写 `localStorage`(key `mebook:book:<id>:pos`),再节流同步服务端;页面隐藏/卸载用 `navigator.sendBeacon` 保证不丢。
- 新增 `web/src/stores/readerSettings.ts`(zustand),与既有 `playProfile.ts` 组织方式一致。
---
## 7. 权限与多用户
`model.UserPermission` 新增 4 位(当前 18 个字段,加后 22 位):
| 权限位 | 默认 | 含义 |
| --- | --- | --- |
| `can_read_books` | `true` | 书架、阅读、进度、批注 |
| `can_manage_book_library` | `false` | 书库 / 书源 CRUD、触发扫描、网盘浏览 |
| `can_manage_books` | `false` | 编辑书籍元信息、删除书、手动重新解析 |
| `can_view_book_stats` | `false` | 查看全站阅读统计 |
同步改动清单(**漏一处就会出现「后端有权限、前端不显示开关」的静默 bug**):
1. `internal/model/permission.go` — 字段、`NewDefaultPermission()`、`PermissionMap()`,并更新文件头注释里的数量描述(注释目前写「19项」,实际 18 个字段,顺手修正)。
2. `web/src/types/auth.ts` — `PermissionFlags` 接口加 4 个字段。
3. `web/src/stores/permissions.ts` — 默认值对象、中文标签映射、权限分组数组。
4. `web/src/hooks/usePermission.ts` — 若其中有分组注释需同步。
5. `internal/handler/permissions.go` — 权限矩阵响应(若有枚举)。
6. `web/src/pages/AdminUsersForm.tsx` / 权限勾选 UI — 若按分组硬编码了列表。
书库可见性:
- 管理员在用户管理页勾选该用户可访问的书库,写入 `User.AllowedBookLibraryIDs`。
- 空值 = 不限制(与影视库语义一致)。
- 过滤集中在一个 `bookVisibility` 助手,与现有的 `internal/handler/visibility.go` 并列(该文件就是影视库可见性的集中判定点,并且会与 `PlayProfile.AllowedLibraryIDs` 求交集)。**阅读模块首版不接播放配置档**——`PlayProfile` 是影视播放器概念(音量、转码参数、PIN),与阅读无关;但判定入口要与它放在同一层,将来若要按配置档限制书库才不用重构。
- **服务端强制**:`GET /api/books/:id`、章节、页面、资源(`/res/*`)、封面、原始文件流,**所有**按 ID 取内容的接口都要校验 `book.LibraryID ∈ 用户可见书库`,不能只靠书架列表过滤。这是最容易漏的越权点:`/api/books/:id/res/*path` 会直接吐出书籍内部的原始资源,漏校验等于开放全库文件读取。
- 用户被取消书库授权后,其 `BookProgress` / `BookAnnotation` 保留不删(授权恢复即恢复),但接口一律按当前可见性判定,不因历史数据放行。
---
## 8. 阅读统计
- **采集**:阅读器每 30s 发一次 `heartbeat`,卸载时补发一次;服务端按 `(user_id, book_id, 小时桶)` 累加,行数上限 = 用户数 × 书数 × 阅读小时数,可控。
- **汇总**:`Scheduler` 每日 03:00 把昨天之前的 session 滚进 `BookDailyStat`(复用 `service.Scheduler` 的既有定时任务注册方式)。
- **展示**:
- 个人页「阅读统计」:累计时长、读完本数、在读本数、近 30 天热力图(仿 GitHub 贡献图)、阅读类型分布(电子书 / 漫画)。
- 首页新增「继续阅读」横滑区块(参照 `HomePageSections.tsx` 里既有区块的写法)。
- 管理员视图:全站活跃度、热门书籍 Top 20(需 `can_view_book_stats`)。
隐私:统计仅对本人与管理员可见;管理员视图只出聚合数据,不暴露单个用户的阅读内容。
---
## 9. 分期实施计划
### P0 — 端到端可用(本地书库 / EPUB + TXT 电子书)
目标:能扫库、能在网页上把一本书读完、关掉浏览器再打开能续读。
| # | 交付物 |
| --- | --- |
| 1 | `internal/model/book.go` 九张表 + `AllModels()` 注册 + 迁移验证(SQLite 与 PostgreSQL 各跑一次升级) |
| 2 | `BookLibrary` / `BookSource` / `Book` / `BookChapter` / `BookProgress` 的 repository |
| 3 | `BookParser`:EPUB 与 TXT 解析(含 GBK 编码探测、章节正则切分、封面提取) |
| 4 | `BookScannerService`:本地目录扫描 + upsert + 进度广播 |
| 5 | API:书库 CRUD、书源 CRUD、扫描、书架列表、详情、章节正文、封面、进度读写、阅读器偏好读写 |
| 6 | 前端:`BookshelfPage`、`BookDetailPage`、`BookReaderPage`(仅滚动模式)、目录抽屉、阅读设置面板 |
| 7 | 权限:4 个权限位 + `AllowedBookLibraryIDs` 全链路(含服务端越权校验) |
**验收标准**
- 一个含 50 本 EPUB 与 20 本 GBK 编码 TXT 的目录,扫描后书架正确列出,标题/作者/封面/章节目录无误。
- 任意一本书可连续阅读 3 章以上,刷新页面后回到原位置(误差 < 1 段)。
- 权限为 `can_read_books=false` 的账号访问 `/api/books` 返回 403;直接请求他人书库的 `/api/books/:id/chapters/0`、`/api/books/:id/res/*`、`/api/books/:id/file` 同样被拒。
- SQLite 单文件档与 PostgreSQL 档都能从旧版本升级启动,无迁移报错。
### P1 — 漫画 + 分页模式 + 网盘直链
| # | 交付物 |
| --- | --- |
| 1 | `ComicReader`:CBZ 解析、单页/双页、右开本、相邻页预加载 |
| 2 | `PagedReader`:分页测量、resize 重排、跨章衔接、键鼠与手势输入 |
| 3 | 网盘书源:`StorageKind=cloud` 的书源配置、`cloud.Provider` 接入、本地缓存目录 + LRU 淘汰 |
| 4 | 网盘书籍的索引拉取与阅读反代(含 `Content-Range` 透传) |
| 5 | PDF:`PdfReader`(pdf.js)+ Range 文件流接口 |
| 6 | 图片文件夹型漫画 |
**验收标准**
- CBZ 单册 300 页可流畅翻阅,双页模式断页处理正确(避免跨章错配)。
- 分页模式下切换字号、resize 窗口、手机横竖屏切换后,位置不跳、不出现空白页。
- 挂在 OpenList 与 115 上的 EPUB 能正常入库并在线阅读,缓存目录达到上限后按 LRU 淘汰且不影响已有进度。
- 20MB 以上 PDF 可跳页、可缩放。
### P2 — 批注、统计与体验打磨
| # | 交付物 |
| --- | --- |
| 1 | 划线 / 书签 / 笔记:`BookAnnotation` 接口与 UI,批注列表与跳转 |
| 2 | 阅读统计:心跳采集、每日汇总任务、个人统计页、首页「继续阅读」区块 |
| 3 | 管理员统计视图 + 热门书籍排行 |
| 4 | 书库定时扫描(`Scheduler` 接入,默认关闭) |
| 5 | 书架高级筛选与排序、合集(系列)聚合视图 |
| 6 | MOBI/AZW3 元信息解析(仍不做在线阅读,仅提供下载) |
| 7 | 部署文档与 compose 注释更新(书籍目录挂载说明) |
### P3 — 可选增强
听书 TTS、跨设备批注冲突合并、书源自动整理(仿 `OrganizerService`)、EPUB 阅读器内注释锚点高亮。
---
## 10. 风险与待拍板项
### 10.1 需要你拍板的两点
**① 网盘书籍的缓存策略**
- 选项 A(本方案):索引时完整下载到缓存目录,阅读时优先本地。省流量、体验好,但全新书首次打开有等待,且占用磁盘(默认 2GB 上限)。
- 选项 B:完全不落盘,每次按 Range/整文件从网盘拉。省磁盘,但每次打开都要重新下载,网盘限速时体验很差。
- 选项 C:折中——只对 EPUB/CBZ 缓存(解析必须读全文),漫画原图与 PDF 走流式。
我的建议是 **C**,因为它把「必须落盘」和「可以不落盘」分开了。
**② 章节正文的返回格式**
- JSON blocks(本方案推荐):安全、便于分页测量,但复杂 EPUB 排版(表格、脚注、竖排)会降级。
- 清洗后 HTML:保真度高,但前端要 `dangerouslySetInnerHTML`,XSS 面更大。
- 我的建议是 **JSON blocks 为主 + `?format=html` 回退**,两者都实现,前端在遇到 `type: "html-block"` 时回退渲染。
### 10.2 技术风险
| 风险 | 影响 | 缓解 |
| --- | --- | --- |
| 自研分页内核的边界情况多(残页、跨章、RTL、竖排) | P1 可能超期 | P0 先只做滚动模式;分页单独立项,配套 `playerPageModel.test.ts` 那样的单测 |
| TXT 章节正则对网文变体覆盖不足 | 目录错乱 | 提供「手动重新切分」入口,规则可配(仿 `RecognitionWordsPanel` 的可配置词表模式) |
| 网盘直链失效 / 限速 / 防盗链 | 阅读中断 | 复用现有 115 换链与 `url_cache.go` 的缓存机制;失败时前端降级为「下载原文件」 |
| 大 TXT(>50MB)章节表行数过多 | SQLite 写入慢 | 章节超过阈值(如 5000 章)时按固定窗口粗切,或改为「按需切分 + 缓存到章节表」的惰性策略 |
| 缓存目录膨胀 | 磁盘打满 | 容量上限 + 复用 `service.PruneImageCache` 的 LRU 清理 + 系统设置页可见 |
| 数据库迁移对老库不兼容 | 升级失败 | 新表全部是纯新增,无列变更;不触碰 `ensurePostgresColumnCompatibility` 的既有语句 |
### 10.3 不引入的新依赖清单
| 依赖 | 用途 | 取舍 |
| --- | --- | --- |
| `dompurify` | 前端 HTML 清洗兜底 | **建议引入**(前端必需) |
| `pdfjs-dist` | PDF 渲染 | **建议引入**(P1) |
| `github.com/nwaples/rardecode` | CBR 解压 | 可选;不接受则 CBR 首版只入库 |
| `epub.js` / `foliate-js` | EPUB 渲染 | **不引入**,见 6.3 |
---
## 11. 测试策略
与项目现有测试密度对齐(`internal/service` 下大量 `_test.go`,前端有 `*.test.ts`):
**后端**
- `book_parser_test.go`:EPUB / TXT 各准备 fixture(`testdata/` 下小体积样本),断言元信息、章节数、章节边界字节偏移、GBK 转码正确性。
- `book_scanner_test.go`:临时目录扫描 + 重复扫描幂等 + 源文件删除后软删。
- `book_progress_test.go`:并发 upsert 不产生重复行(直接复现 `dedupePlaybackHistories` 防的那类 bug)。
- `book_permission_test.go`:越权矩阵,逐接口断言非可见书库返回 403/404。
- Handler 层:仿 `internal/handler/media_test.go` 起的 `httptest` + 真实内存 SQLite。
**前端**
- `readerModel.test.ts`:模式切换时的位置换算(`scroll ↔ paged`、`charOffset ↔ pageIndex`)、百分比计算、跨章边界。
- 分页计算的纯函数抽出单测(不含 DOM),参照 `web/src/pages/playerPageModel.test.ts` 的做法——把逻辑从组件里拔出来测,是项目已有的好传统。
---
## 12. 附:改动文件清单
**后端新增**
```
internal/model/book.go
internal/repository/book_repository.go
internal/service/book_parser.go EPUB / TXT / CBZ 解析
internal/service/book_parser_epub.go
internal/service/book_parser_txt.go
internal/service/book_parser_comic.go
internal/service/book_scanner.go
internal/service/book_reader.go 章节 / 页面 / 资源的读取与清洗
internal/service/book_progress.go
internal/service/book_stats.go
internal/service/book_cache.go 网盘缓存与 LRU
internal/handler/books.go
internal/handler/books_library.go
internal/handler/books_reader.go
internal/handler/routes_books.go
```
**后端修改**
```
internal/model/model.go AllModels() 追加 9 张表
internal/model/permission.go 4 个权限位
internal/model/user.go ReaderSettings / AllowedBookLibraryIDs
internal/repository/repository.go Container 加字段
internal/service/service.go Container 加字段 + Boot() 启动扫描
internal/handler/routes_authenticated.go 挂载 registerAuthedBookRoutes
internal/service/scheduler_local_jobs.go 书籍缓存清理 + 每日阅读统计汇总
internal/config/types.go CacheConfig 加 BooksMaxSizeMB;新增 BookConfig(扫描并发等)
docker-compose*.yml 书籍目录挂载注释
README.md / README_EN.md 能力表新增「阅读」
```
**前端新增**
```
web/src/api/books.ts
web/src/stores/readerSettings.ts
web/src/pages/BookshelfPage.tsx
web/src/pages/BookDetailPage.tsx
web/src/pages/BookReaderPage.tsx
web/src/pages/BookStatsPage.tsx
web/src/pages/BookLibraryPage.tsx
web/src/components/book/*.tsx
```
**前端修改**
```
web/src/appRoutes.tsx 4 条路由
web/src/components/layoutNavigation.ts 导航项、阅读器路由判定、返回链
web/src/types/auth.ts 权限位
web/src/stores/permissions.ts 权限位默认值 / 标签 / 分组
web/src/pages/HomePageSections.tsx 「继续阅读」区块
web/src/pages/settingsGroupBooks.ts (新增)阅读设置分组
web/src/pages/settingsGroups.ts 把 settingsGroupBooks 加入 GROUPS 数组
```
+10 -8
View File
@@ -3,6 +3,7 @@ module github.com/truewhile/MeBox
go 1.25.0
require (
fyne.io/systray v1.12.2
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/gzip v1.2.6
@@ -12,16 +13,16 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metatube-community/metatube-sdk-go v1.4.0
github.com/redis/go-redis/v9 v9.7.0
github.com/redis/go-redis/v9 v9.7.3
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/image v0.45.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
@@ -48,10 +49,11 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -69,7 +71,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -89,7 +91,7 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.70.0 // indirect
+24 -20
View File
@@ -1,3 +1,5 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
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=
@@ -56,6 +58,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -77,8 +81,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -121,10 +125,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -171,8 +175,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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@@ -192,28 +196,28 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+10
View File
@@ -0,0 +1,10 @@
// Package brand contains the embedded MeBox application artwork.
package brand
import _ "embed"
// Icon is the Windows ICO used by both the executable resource and the
// notification-area icon.
//
//go:embed logo.ico
var Icon []byte
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+6 -1
View File
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
if z.log == nil {
return
}
z.log.Sugar().Infof(format, args...)
message := fmt.Sprintf(format, args...)
if strings.Contains(strings.ToLower(message), "context canceled") {
z.log.Debug(message)
return
}
z.log.Info(message)
}
+148
View File
@@ -0,0 +1,148 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
quality, _ := strconv.Atoi(c.Query("quality"))
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
if err != nil {
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
var req struct {
Definition int `json:"definition"`
}
_ = c.ShouldBindJSON(&req)
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
definition, _ := strconv.Atoi(c.Query("definition"))
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
if err == nil {
return
}
if c.Writer.Written() {
return
}
if errors.Is(err, service.ErrCloud115TranscodePending) {
retryAfter := 5
message := "115 正在转码,请稍候"
var info *service.PlaybackInfo
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
info = latest
if latest.Transcode.Message != "" {
message = latest.Transcode.Message
}
if latest.Transcode.RetryAfterSec > 0 {
retryAfter = latest.Transcode.RetryAfterSec
}
}
c.JSON(http.StatusConflict, gin.H{
"code": "transcode_pending",
"message": message,
"retry_after_sec": retryAfter,
"playback": info,
})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
mediaID := c.Query("media_id")
if mediaID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
c.Param("session"),
c.Param("key"),
)
if err == nil || c.Writer.Written() {
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
}
+60 -4
View File
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if cfg.PlaybackRate != 1 {
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(t)
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
@@ -154,7 +158,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg)
}
if !cfg.MergeSources || cfg.Volume != 0.35 {
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
@@ -165,11 +169,63 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
svc := newDanmakuSettingsService(t)
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
if initial.Vr360GuideSeen {
t.Fatal("vr360 guide should be unread for a fresh user")
}
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"vr360_guide_seen":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.Vr360GuideSeen {
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if !user.PlayerVr360GuideSeen {
t.Fatalf("guide flag not persisted on the user: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"playback_rate":4}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerPlaybackRate != 1 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
+34 -34
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
Limit: limit,
StartIndex: offset,
SeasonIndex: parseEmbySeasonIndexQuery(c),
}
}
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
//
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
if raw == "" {
return nil
}
value, err := strconv.Atoi(raw)
if err != nil {
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
return nil
}
return &value
}
func embyFirstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,13 +130,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
@@ -147,13 +145,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +201,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -226,15 +218,23 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "SeasonId", "seasonId")
// 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤,
// /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。
seasonIndex := parseEmbySeasonIndexQuery(c)
if parentID == "" {
parentID = c.Param("id")
} else {
// SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者
// 不一致时把结果过滤成空集。
seasonIndex = nil
}
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
if err != nil {
+2 -8
View File
@@ -13,10 +13,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
+2 -8
View File
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -0,0 +1,211 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// embySeasonEpisodesRouter builds an Emby-compatible router over one series with
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
// real clients send.
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for _, m := range []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
} {
if err := db.Create(&m).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
// Resolve the virtual series id the way a client would.
seriesReq := httptest.NewRequest(http.MethodGet, "/Items?ParentId="+lib.ID+"&IncludeItemTypes=Series", nil)
seriesReq.Header.Set("X-Emby-Token", signedTestToken(t, secret))
seriesRec := httptest.NewRecorder()
router.ServeHTTP(seriesRec, seriesReq)
if seriesRec.Code != http.StatusOK {
t.Fatalf("series lookup status=%d body=%s", seriesRec.Code, seriesRec.Body.String())
}
var seriesPayload struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(seriesRec.Body.Bytes(), &seriesPayload); err != nil {
t.Fatalf("decode series: %v", err)
}
if len(seriesPayload.Items) != 1 {
t.Fatalf("want one series, got %#v", seriesPayload.Items)
}
seriesID, _ := seriesPayload.Items[0]["Id"].(string)
if seriesID == "" {
t.Fatalf("series payload has no Id: %#v", seriesPayload.Items[0])
}
return router, secret, seriesID
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
}
var payload struct {
Items []map[string]any `json:"Items"`
TotalRecordCount float64 `json:"TotalRecordCount"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
ids := make([]string, 0, len(payload.Items))
for _, item := range payload.Items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
return ids, int(payload.TotalRecordCount)
}
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
// test: Emby clients scope episodes with ?Season=<index> (not only SeasonId), and
// the endpoint used to ignore it and return every season of the series.
func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
cases := []struct {
name string
query string
wantIDs []string
}{
{name: "season 1", query: "Season=1", wantIDs: []string{"s1e1"}},
{name: "season 2", query: "Season=2", wantIDs: []string{"s2e1"}},
{name: "season index alias", query: "SeasonIndex=2", wantIDs: []string{"s2e1"}},
{name: "no season returns all", query: "", wantIDs: []string{"s1e1", "s2e1"}},
{name: "unknown season is empty", query: "Season=9", wantIDs: []string{}},
{name: "non numeric season falls back to all", query: "Season=Specials", wantIDs: []string{"s1e1", "s2e1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := "/Shows/" + seriesID + "/Episodes"
if tc.query != "" {
path += "?" + tc.query
}
ids, total := fetchEpisodeIDs(t, router, secret, path)
if len(ids) != len(tc.wantIDs) {
t.Fatalf("episode ids = %#v (total=%d), want %#v", ids, total, tc.wantIDs)
}
for i := range tc.wantIDs {
if ids[i] != tc.wantIDs[i] {
t.Fatalf("episode ids = %#v, want %#v", ids, tc.wantIDs)
}
}
if total != len(tc.wantIDs) {
t.Fatalf("TotalRecordCount = %d, want %d", total, len(tc.wantIDs))
}
})
}
}
// TestEmbyShowEpisodesSeasonIdStillWins pins that the virtual SeasonId form keeps
// working, and that a conflicting Season query cannot empty it out.
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
seasonsPath := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
var season1ID string
for _, s := range seasons.Items {
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
season1ID, _ = s["Id"].(string)
}
}
if season1ID == "" {
t.Fatalf("season 1 not found in %#v", seasons.Items)
}
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
if len(ids) != 1 || ids[0] != "s1e1" {
t.Fatalf("SeasonId episodes = %#v, want [s1e1]", ids)
}
// A stale/conflicting season number must not override the explicit season id.
ids, _ = fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID+"&Season=2")
if len(ids) != 1 || ids[0] != "s1e1" {
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
}
}
+1 -4
View File
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+1
View File
@@ -19,6 +19,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
api.Use(middleware.GzipAPI())
{
api.GET("/health", healthCheck)
api.HEAD("/health", healthCheck)
api.GET("/version", versionInfo)
api.GET("/public/ui-config", publicUIConfigHandler(svc))
+12 -1
View File
@@ -722,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -758,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
// 画质与原文件一致,不触发转码。
if wantSameOriginProxy(c) {
handled, proxyErr := proxySTRMStream(c, svc, m)
if handled {
writeProxyError(c, proxyErr)
return
}
}
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+40
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
type libraryTagsReq struct {
Tags []model.LibraryTagSet `json:"tags"`
}
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req libraryTagsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
@@ -44,6 +46,8 @@ 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/:id/playback", mediaPlaybackInfoHandler(svc))
authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
@@ -65,6 +69,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.GET("/img", imageProxyHandler(svc))
}
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/me/library-tags",
"PUT /api/me/library-tags",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
+1 -1
View File
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
cards = sortRemoteSeriesCards(cards, sortSpec, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"slices"
"time"
"github.com/truewhile/MeBox/internal/service"
)
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
if spec.Field != "updated_at" {
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
}
out := append([]service.SeriesCard(nil), cards...)
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
if spec.Order == "asc" {
slices.Reverse(out)
}
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import (
"testing"
"github.com/truewhile/MeBox/internal/service"
)
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
}
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
}
}
+56
View File
@@ -0,0 +1,56 @@
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
//
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
// 避免把网盘流量无谓地压到服务器上。
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
func wantSameOriginProxy(c *gin.Context) bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
if c == nil || svc == nil || svc.Strm == nil || m == nil {
return false, nil
}
if !service.IsStrmMediaRow(m) {
return false, nil
}
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
return false, nil
}
return true, err
}
return true, nil
}
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
func writeProxyError(c *gin.Context, err error) {
if c == nil || err == nil || c.Writer.Written() {
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+20 -10
View File
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return
}
now := time.Now()
acct.LastTestAt = &now
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
result := ""
ok := false
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
result = err.Error()
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
result = "ok"
ok = true
}
} else {
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
acct.LastTestAt = &now
acct.LastTestResult = result
acct.LastTestOK = ok
if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil {
// 写库失败时仍返回本地测试结果;不要回退到整行 Update,
// 那会覆盖 TestConnection 期间可能刷新的账号配置。
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil {
acct = fresh
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
}
}
+32 -40
View File
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
var rows []model.PlaybackHistory
if err := svc.Repo.DB.
Where("user_id = ? AND completed = ?", toString(uid), false).
Where("user_id = ? AND completed = ?", userID, false).
Order("watched_at desc").
Limit(limit).
Find(&rows).Error; err != nil {
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
mIdx := make(map[string]model.Media, len(media))
for _, m := range media {
if !mediaVisibleForRequest(c, svc, &m) {
continue
}
mIdx[m.ID] = m
}
out := make([]gin.H, 0, len(rows))
staleIDs := make([]string, 0)
for _, r := range rows {
m, ok := mIdx[r.MediaID]
if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
if ok {
if mediaVisibleForRequest(c, svc, &m) {
out = append(out, gin.H{
"history": r,
"media": *fallback,
"media": m,
})
}
continue
}
out = append(out, gin.H{
"history": r,
"media": m,
})
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if mediaVisibleForRequest(c, svc, remoteMedia) {
out = append(out, gin.H{
"history": r,
"media": *remoteMedia,
})
}
continue
}
}
}
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
// 因此清理这条失效播放记录,不再占用继续观看列表。
staleIDs = append(staleIDs, r.MediaID)
}
if len(staleIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
Delete(&model.PlaybackHistory{}).Error
}
c.JSON(http.StatusOK, out)
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+105
View File
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
valid := model.Media{
Base: model.Base{ID: "media-valid"},
Title: "有效影片",
Path: "/media/valid.mkv",
}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-valid"},
UserID: "user-1",
MediaID: valid.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-stale"},
UserID: "user-1",
MediaID: "media-deleted",
PositionMs: 60_000,
DurationMs: 120_000,
WatchedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history/continue", historyContinueHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body []struct {
History model.PlaybackHistory `json:"history"`
Media model.Media `json:"media"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
}
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
t.Fatalf("unexpected row: %#v", body[0])
}
var staleCount int64
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
Count(&staleCount).Error; err != nil {
t.Fatal(err)
}
if staleCount != 0 {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
+2
View File
@@ -125,6 +125,8 @@ func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool {
return len(segments) >= 3 &&
strings.EqualFold(segments[2], "play") &&
strings.TrimSpace(c.Query("media_id")) == mediaID
case "cloud115":
return strings.TrimSpace(c.Query("media_id")) == mediaID
}
}
return false
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+18 -8
View File
@@ -28,18 +28,27 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// ExpiredAt is the account expiry time. Nil means the account never
@@ -102,4 +111,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}
+15
View File
@@ -57,6 +57,21 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount
})
}
// UpdateTestResult updates only connectivity-test metadata. Callers that touch
// account credentials must not use Update with a snapshot read before Ping:
// a 115 token refresh can persist new tokens while Ping is running, and writing
// the stale snapshot back would revoke the freshly rotated credentials.
func (r *StrmAccountRepository) UpdateTestResult(ctx context.Context, id string, at time.Time, result string, ok bool) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", id).Updates(map[string]any{
"last_test_at": at,
"last_test_result": result,
"last_test_ok": ok,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error
+186
View File
@@ -0,0 +1,186 @@
// 115 开放平台视频播放/转码 API。
//
// 播放接口返回的是 115 云端转码后的 HLS(m3u8)地址,不是原文件
// downurl;未完成转码时接口会返回 state=false,可再调用 video_push
// 请求加速转码。
package cloud115
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
)
// FlexString 兼容 115 接口同字段在不同设备端返回字符串或数字的情况。
type FlexString string
func (s *FlexString) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
*s = ""
return nil
}
var str string
if err := json.Unmarshal(raw, &str); err == nil {
*s = FlexString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
*s = FlexString(num.String())
return nil
}
return fmt.Errorf("115: 无法解析字符串/数字字段 %s", string(raw))
}
func (s FlexString) String() string {
return string(s)
}
func (s FlexString) Int64() int64 {
v, _ := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 64)
return v
}
// MultiTrack 是 115 返回的一条音轨信息。
type MultiTrack struct {
Title string `json:"title"`
IsSelected string `json:"is_selected"`
SyncTime string `json:"sync_time"`
}
// MultiTrackList 兼容 115 接口把 multitrack_list 返回成对象(键为音轨序号)
// 或数组的两种形态。
type MultiTrackList []MultiTrack
func (l *MultiTrackList) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte("{}")) {
*l = nil
return nil
}
if raw[0] == '[' {
var arr []MultiTrack
if err := json.Unmarshal(raw, &arr); err != nil {
return err
}
*l = arr
return nil
}
if raw[0] == '{' {
var m map[string]MultiTrack
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
keys := make([]int, 0, len(m))
for key := range m {
idx, err := strconv.Atoi(strings.TrimSpace(key))
if err != nil {
continue
}
keys = append(keys, idx)
}
sort.Ints(keys)
out := make([]MultiTrack, 0, len(keys))
for _, idx := range keys {
out = append(out, m[strconv.Itoa(idx)])
}
*l = out
return nil
}
return fmt.Errorf("115: multitrack_list 既不是对象也不是数组")
}
// VideoURLItem 是 115 返回的一条清晰度播放地址。
type VideoURLItem struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
Definition int `json:"definition"`
Title string `json:"title"`
DefinitionN int `json:"definition_n"`
}
// VideoPlayData 是 /open/video/play 的 data 字段。
type VideoPlayData struct {
FileID string `json:"file_id"`
ParentID string `json:"parent_id"`
FileName string `json:"file_name"`
FileSize FlexString `json:"file_size"`
FileSha1 string `json:"file_sha1"`
FileType string `json:"file_type"`
IsPrivate FlexString `json:"is_private"`
PlayLong FlexString `json:"play_long"`
UserDef int `json:"user_def"`
UserRotate int `json:"user_rotate"`
UserTurn int `json:"user_turn"`
MultitrackList MultiTrackList `json:"multitrack_list"`
DefinitionList map[string]string `json:"definition_list"`
DefinitionListNew map[string]string `json:"definition_list_new"`
VideoURL []VideoURLItem `json:"video_url"`
VideoPushState *bool `json:"video_push_state"`
}
// GetVideoPlayInfo 调用 115 视频在线播放接口。
//
// 注意:state=false(例如视频尚未转码)时 doAuthJSON 会返回 error,但仍会
// 返回已解析的 RespBase/VideoPlayData。调用方需要同时检查两者:有 data 时
// 可用于判断需要等待还是触发加速转码。
func (c *OpenClient) GetVideoPlayInfo(ctx context.Context, pickCode, ua string) (*RespBase, *VideoPlayData, error) {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return nil, nil, fmt.Errorf("115: pick_code 为空")
}
resp, err := c.doAuthJSONWithUA(
ctx,
http.MethodGet,
ProAPIBase+"/open/video/play",
map[string]string{"pick_code": pickCode},
1,
ua,
)
if resp == nil {
return nil, nil, err
}
data := &VideoPlayData{}
if len(resp.Data) > 0 {
trimmed := bytes.TrimSpace(resp.Data)
if !bytes.Equal(trimmed, []byte("null")) {
if decodeErr := json.Unmarshal(trimmed, data); decodeErr != nil && err == nil {
err = fmt.Errorf("115: 解析视频播放信息失败:%w", decodeErr)
}
}
}
return resp, data, err
}
// SubmitVideoPush 提交 115 加速转码请求。
//
// op 支持 vip_push(按 VIP 等级加速)和 pay_push(消耗枫币)。调用方必须
// 明确选择 op,默认空值按 vip_push 处理,避免误消费枫币。
func (c *OpenClient) SubmitVideoPush(ctx context.Context, pickCode, op string) error {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return fmt.Errorf("115: pick_code 为空")
}
op = strings.TrimSpace(op)
if op == "" {
op = "vip_push"
}
_, err := c.doAuthJSON(
ctx,
http.MethodPost,
ProAPIBase+"/open/video/video_push",
map[string]string{
"pick_code": pickCode,
"op": op,
},
1,
)
return err
}
+361
View File
@@ -0,0 +1,361 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
)
const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
)
type cloud115HLSSession struct {
ID string
MediaID string
Definition int
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
entries map[string]string
next int
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
//
// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许
// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表,
// 分片按 Range 流式转发。
type Cloud115HLSProxy struct {
service *Cloud115PlaybackService
client *http.Client
mu sync.Mutex
sessions map[string]*cloud115HLSSession
}
func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy {
return &Cloud115HLSProxy{
service: service,
client: &http.Client{
Timeout: 0,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 6 {
return errors.New("stopped after 6 redirects")
}
return nil
},
},
sessions: make(map[string]*cloud115HLSSession),
}
}
// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。
func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error {
if p == nil || p.service == nil {
return ErrCloud115NotApplicable
}
upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition)
if err != nil {
return err
}
session := p.newSession(mediaID, definition)
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := p.fetchUpstream(fetchCtx, upstream, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
p.storeSession(session)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。
func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error {
session, upstream, ok := p.lookup(sessionID, key)
if !ok {
return ErrCloud115HLSSessionNotFound
}
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone {
p.deleteSession(sessionID)
return ErrCloud115HLSUpstreamExpired
}
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
isPlaylist := strings.Contains(contentType, "mpegurl") ||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
}
copyUpstreamHeaders(w, resp, isPlaylist)
if isPlaylist {
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.Copy(w, resp.Body)
}
return err
}
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
now := time.Now()
return &cloud115HLSSession{
ID: cloud115.RandomString(24),
MediaID: mediaID,
Definition: definition,
CreatedAt: now,
ExpiresAt: now.Add(cloud115HLSSessionTTL),
entries: make(map[string]string),
}
}
func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
if p == nil || session == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if len(p.sessions) >= cloud115HLSMaxSessions {
for id, existing := range p.sessions {
if now.After(existing.ExpiresAt) {
delete(p.sessions, id)
}
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
p.sessions[session.ID] = session
}
func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) {
if p == nil {
return nil, "", false
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
}
p.mu.Unlock()
if session == nil {
return nil, "", false
}
session.mu.Lock()
upstream, ok := session.entries[key]
session.mu.Unlock()
return session, upstream, ok
}
func (p *Cloud115HLSProxy) deleteSession(sessionID string) {
if p == nil {
return
}
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
}
func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) {
return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", cloud115.DefaultUA)
if strings.TrimSpace(rangeHeader) != "" {
req.Header.Set("Range", rangeHeader)
}
return p.client.Do(req)
}
func isAllowed115UpstreamHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return host == "115.com" || host == "115cdn.com" || host == "115cdn.net"
}
func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string {
if session == nil {
return text
}
lines := strings.SplitAfter(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") ||
strings.HasPrefix(trimmed, "#EXT-X-KEY:") ||
strings.HasPrefix(trimmed, "#EXT-X-MAP:") {
lines[i] = replaceManifestURI(line, func(uri string) string {
return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery)
})
}
continue
}
lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line)
}
return strings.Join(lines, "")
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
mediaID := session.MediaID
session.mu.Unlock()
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
}
func childProxyQuery(rawQuery, mediaID string) string {
values, _ := url.ParseQuery(rawQuery)
keep := url.Values{}
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} {
if value := strings.TrimSpace(values.Get(key)); value != "" {
keep.Set(key, value)
}
}
if strings.TrimSpace(mediaID) != "" {
keep.Set("media_id", mediaID)
}
return keep.Encode()
}
func replaceManifestURI(line string, replace func(string) string) string {
const marker = `URI="`
idx := strings.Index(line, marker)
if idx < 0 {
return line
}
start := idx + len(marker)
end := strings.Index(line[start:], `"`)
if end < 0 {
return line
}
end += start
return line[:start] + replace(line[start:end]) + line[end:]
}
func resolveManifestURL(baseURL, raw string) string {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
ref, refErr := url.Parse(strings.TrimSpace(raw))
if baseErr != nil || refErr != nil || base == nil || ref == nil {
return raw
}
return base.ResolveReference(ref).String()
}
func lineEnding(line string) string {
if strings.HasSuffix(line, "\r\n") {
return "\r\n"
}
if strings.HasSuffix(line, "\n") {
return "\n"
}
return ""
}
func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) {
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(key); value != "" {
w.Header().Set(key, value)
}
}
if playlist {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
} else if w.Header().Get("Cache-Control") == "" {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}
+151
View File
@@ -0,0 +1,151 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720\nhttps://cpats01.115.com/a.m3u8?u=1&se=2\n"
rewritten := proxy.rewriteManifest(session, manifest, "http://videoplay.115.com/m3u8/pc", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
t.Fatalf("auth/media query missing: %s", rewritten)
}
if len(session.entries) != 1 {
t.Fatalf("session entries = %d, want 1", len(session.entries))
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := `#EXTM3U
#EXT-X-KEY:METHOD=AES-128,URI="https://cpats01.115.com/key?k=1"
#EXTINF:10.0,
https://cpats01.115.com/seg.ts?x=1
`
rewritten := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten key manifest: %s", rewritten)
}
if len(session.entries) != 2 {
t.Fatalf("session entries = %d, want 2", len(session.entries))
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be allowed", host)
}
}
for _, host := range []string{"", "evil.example.com", "115.com.evil.example.com"} {
if isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be rejected", host)
}
}
}
func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := "#EXTM3U\n#EXT-X-TARGETDURATION:10\nhttps://cpats01.115.com/seg.ts?x=1\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/vnd.apple.mpegurl"},
},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
t.Fatalf("upstream URL leaked in variant: %s", rec.Body.String())
}
}
func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
payload := []byte("segment-bytes")
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusPartialContent,
Header: http.Header{
"Content-Type": []string{"video/mp2t"},
"Content-Range": []string{"bytes 0-12/100"},
"Content-Length": []string{strconv.Itoa(len(payload))},
},
Body: io.NopCloser(bytes.NewReader(payload)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
req.Header.Set("Range", "bytes=0-12")
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if rec.Header().Get("Content-Range") != "bytes 0-12/100" {
t.Fatalf("content-range = %q", rec.Header().Get("Content-Range"))
}
if !bytes.Equal(rec.Body.Bytes(), payload) {
t.Fatalf("payload = %q, want %q", rec.Body.Bytes(), payload)
}
}
+431
View File
@@ -0,0 +1,431 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115TranscodePending = errors.New("115 cloud transcode pending")
ErrCloud115NotApplicable = errors.New("115 cloud playback not applicable")
)
// Cloud115TranscodeState 描述当前请求清晰度的云端转码状态。
type Cloud115TranscodeState struct {
State string `json:"state"` // idle / ready / transcoding / unavailable
Definition string `json:"definition,omitempty"`
Message string `json:"message,omitempty"`
RetryAfterSec int `json:"retry_after_sec,omitempty"`
StartedAt int64 `json:"started_at,omitempty"`
}
// PlaybackInfo 是播放器加载时获取的统一播放能力描述。
type PlaybackInfo struct {
MediaID string `json:"media_id"`
Provider string `json:"provider"`
Fallback []string `json:"fallback"`
DefaultQuality string `json:"default_quality"`
CloudQualities []PlaybackQuality `json:"cloud_qualities,omitempty"`
LocalQualities []PlaybackQuality `json:"local_qualities"`
Transcode Cloud115TranscodeState `json:"transcode"`
}
type cloud115PushAttempt struct {
at time.Time
success bool
message string
}
// Cloud115PlaybackService 负责 115 云端播放、清晰度列表和云端转码触发。
// 实际的 HLS 代理在 Cloud115HLSProxy 中。
type Cloud115PlaybackService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
strm *StrmService
// transcoder 用于判断本地 HLS 档位此刻是否真的可用;未注入时按"不可用"处理,
// 避免向播放器推荐必然 500 的转码档位。
transcoder *TranscoderService
mu sync.Mutex
pushState map[string]cloud115PushAttempt
proxy *Cloud115HLSProxy
}
// SetTranscoder 注入转码服务,用于标注本地 HLS 档位的可用性。
func (s *Cloud115PlaybackService) SetTranscoder(transcoder *TranscoderService) *Cloud115PlaybackService {
if s != nil {
s.transcoder = transcoder
}
return s
}
// localTranscodeAvailable 报告本地 HLS 此刻是否可用。
func (s *Cloud115PlaybackService) localTranscodeAvailable() bool {
if s == nil || s.transcoder == nil {
return false
}
return s.transcoder.Available()
}
func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService {
svc := &Cloud115PlaybackService{
cfg: cfg,
log: log,
repo: repo,
strm: strm,
pushState: make(map[string]cloud115PushAttempt),
}
svc.proxy = newCloud115HLSProxy(svc)
return svc
}
// HLSProxy 返回 115 云 HLS 反向代理。
func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
if s == nil {
return nil
}
return s.proxy
}
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
//
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, ErrMediaNotFound
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, ErrMediaNotFound
}
provider := MediaPlaybackProvider(m)
localQualities := LocalQualityOptions(m, s.localTranscodeAvailable())
localDefault := DefaultLocalHLSQualityID(m)
info := &PlaybackInfo{
MediaID: m.ID,
Provider: provider,
DefaultQuality: localDefault,
LocalQualities: localQualities,
Transcode: Cloud115TranscodeState{State: "idle"},
}
if provider != model.StrmProvider115 {
info.Fallback = []string{"direct", "local_hls"}
return info, nil
}
account, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{State: "unavailable", Message: err.Error()}
return info, nil
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Message: cloud115PlayErrorMessage(playErr, resp),
}
return info, nil
}
cloudQualities := Cloud115QualityOptions(m, data)
info.CloudQualities = cloudQualities
info.Fallback = []string{"direct", "cloud_hls", "local_hls"}
info.DefaultQuality = DefaultCloud115Quality(cloudQualities)
requested := strings.TrimSpace(strconv.Itoa(requestedDefinition))
if requestedDefinition <= 0 || requested == "0" {
requested = info.DefaultQuality
}
selected, ok := findPlaybackQuality(cloudQualities, requested)
if !ok {
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Definition: requested,
Message: "115 不支持该清晰度",
}
return info, nil
}
if selected.Source == "original" {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
if selected.Available {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
return info, nil
}
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
return s.PlaybackInfo(ctx, mediaID, definition)
}
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
// 档位尚未转码完成时返回 ErrCloud115TranscodePending。
func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaID string, definition int) (string, *cloud115.VideoPlayData, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", nil, err
}
if m == nil {
return "", nil, ErrMediaNotFound
}
if MediaPlaybackProvider(m) != model.StrmProvider115 {
return "", nil, ErrCloud115NotApplicable
}
_, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
return "", nil, err
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
return "", data, fmt.Errorf("115 云端播放不可用:%s", cloud115PlayErrorMessage(playErr, resp))
}
requested := definition
if requested <= 0 {
requested = 4
}
item := findVideoURL(data, requested)
if item == nil || strings.TrimSpace(item.URL) == "" {
return "", data, ErrCloud115TranscodePending
}
return strings.TrimSpace(item.URL), data, nil
}
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
quality PlaybackQuality,
client *cloud115.OpenClient,
) Cloud115TranscodeState {
key := accountID + ":" + pickCode
now := time.Now()
s.mu.Lock()
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
ttl = 3 * time.Hour
}
if now.Sub(last.at) < ttl {
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: last.message,
RetryAfterSec: 5,
StartedAt: last.at.Unix(),
}
}
}
s.mu.Unlock()
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
attempt := cloud115PushAttempt{at: now, success: err == nil}
if err != nil {
attempt.message = fmt.Sprintf("115 云端转码排队中(加速请求未成功:%s)", cloud115PlayErrorMessage(err, nil))
} else {
attempt.message = fmt.Sprintf("已触发 115 云端转码,正在等待 %s…", quality.Label)
}
s.mu.Lock()
s.pushState[key] = attempt
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: attempt.message,
RetryAfterSec: 5,
StartedAt: now.Unix(),
}
}
func (s *Cloud115PlaybackService) resolve115Target(ctx context.Context, m *model.Media) (*model.StrmAccount, string, *cloud115.OpenClient, error) {
if s == nil || s.strm == nil || s.repo == nil || s.repo.StrmAccount == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err != nil {
return nil, "", nil, err
}
raw = strings.TrimSpace(parsed)
}
if raw == "" {
return nil, "", nil, fmt.Errorf("缺少 STRM 播放目标")
}
u, err := url.Parse(raw)
if err != nil {
return nil, "", nil, err
}
if !strings.Contains(strings.ToLower(u.Path), "/api/strm/play/cloud115/") &&
!strings.Contains(strings.ToLower(u.Path), "/api/cloud/play/cloud115") {
return nil, "", nil, ErrCloud115NotApplicable
}
accountID := strings.TrimSpace(u.Query().Get("acct"))
if accountID == "" {
return nil, "", nil, fmt.Errorf("缺少 115 账号 ID")
}
pickCode := strings.TrimSpace(u.Query().Get("pickcode"))
if pickCode == "" {
return nil, "", nil, fmt.Errorf("缺少 115 pickcode")
}
account, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil {
return nil, "", nil, err
}
if account == nil || !account.Enabled {
return nil, "", nil, fmt.Errorf("115 账号不存在或已禁用")
}
if account.Provider != model.StrmProvider115 {
return nil, "", nil, ErrCloud115NotApplicable
}
provider, err := s.strm.providerFor(ctx, account)
if err != nil {
return nil, "", nil, err
}
openProvider, ok := provider.(cloud.OpenAPI115Provider)
if !ok || openProvider.OpenClient() == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
return account, pickCode, openProvider.OpenClient(), nil
}
// MediaPlaybackProvider 返回媒体行的播放提供方(用于前端降级顺序)。
func MediaPlaybackProvider(m *model.Media) string {
if m == nil {
return model.StrmProviderLocal
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
if target, err := readLocalSTRMTarget(m.Path); err == nil {
raw = strings.TrimSpace(target)
}
}
if raw != "" {
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
if idx := strings.Index(path, "/api/strm/play/"); idx >= 0 {
rest := strings.Trim(path[idx+len("/api/strm/play/"):], "/")
if provider, _, ok := strings.Cut(rest, "/"); ok && provider != "" {
return provider
}
}
if idx := strings.Index(path, "/api/cloud/play/"); idx >= 0 {
provider := strings.Trim(path[idx+len("/api/cloud/play/"):], "/")
if provider != "" {
return provider
}
}
}
}
if IsStrmMediaRow(m) {
return "strm"
}
return model.StrmProviderLocal
}
func findPlaybackQuality(options []PlaybackQuality, id string) (PlaybackQuality, bool) {
id = strings.TrimSpace(id)
for _, option := range options {
if option.ID == id {
return option, true
}
}
return PlaybackQuality{}, false
}
func findVideoURL(data *cloud115.VideoPlayData, definition int) *cloud115.VideoURLItem {
if data == nil || definition <= 0 {
return nil
}
for i := range data.VideoURL {
item := &data.VideoURL[i]
if item.DefinitionN == definition || item.Definition == definition {
return item
}
}
return nil
}
func cloud115PlayInfoFatal(resp *cloud115.RespBase, err error) bool {
if resp == nil {
return true
}
if bool(resp.State) {
return false
}
switch resp.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid,
cloud115.TokenRefreshFail,
cloud115.RequestMaxLimitCode,
cloud115.RequestRateLimitCode:
return true
}
// 非鉴权/限流错误一律当作“尚未转码完成”处理,继续轮询。
_ = err
return false
}
func cloud115PlayErrorMessage(err error, resp *cloud115.RespBase) string {
if err != nil {
var apiErr *cloud115.OpenAPIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid:
return "115 授权已过期,请重新授权账号"
}
if strings.TrimSpace(apiErr.Message) != "" {
return apiErr.Message
}
}
return err.Error()
}
if resp != nil {
if msg := strings.TrimSpace(resp.Message); msg != "" {
return msg
}
if msg := strings.TrimSpace(resp.Error); msg != "" {
return msg
}
}
return "115 云端播放不可用"
}
+203
View File
@@ -0,0 +1,203 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
var playCalls int32
var pushCalls int32
var ready int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
atomic.AddInt32(&playCalls, 1)
if atomic.LoadInt32(&ready) == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{
"state": false,
"code": 0,
"message": "转码中",
"data": map[string]any{},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"state": true,
"code": 0,
"message": "",
"data": map[string]any{
"file_name": "test.mkv",
"definition_list_new": map[string]string{
"4": "1080P",
},
"video_url": []map[string]any{
{
"url": "http://cdn.example/master.m3u8",
"definition": 4,
"definition_n": 4,
"width": 1920,
"height": 1080,
"title": "1080P",
},
},
},
})
case "/open/video/video_push":
atomic.AddInt32(&pushCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-1"},
Title: "test",
Path: "/media/test.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("first playback info: %v", err)
}
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
t.Fatalf("unexpected playback info: %#v", info)
}
if info.Transcode.State != "transcoding" {
t.Fatalf("transcode state = %q, want transcoding", info.Transcode.State)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls = %d, want 1", got)
}
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
t.Fatalf("second playback info: %v", err)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls after polling = %d, want 1", got)
}
atomic.StoreInt32(&ready, 1)
info, err = svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("ready playback info: %v", err)
}
if info.Transcode.State != "ready" {
t.Fatalf("transcode state = %q, want ready", info.Transcode.State)
}
url, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4)
if err != nil {
t.Fatalf("resolve cloud url: %v", err)
}
if url != "http://cdn.example/master.m3u8" {
t.Fatalf("url = %q", url)
}
if got := atomic.LoadInt32(&playCalls); got < 3 {
t.Fatalf("play calls = %d, want >= 3", got)
}
}
func TestCloud115PlaybackInfoTranscodePendingBeforeReady(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
_ = json.NewEncoder(w).Encode(map[string]any{"state": false, "code": 0, "message": "转码中", "data": map[string]any{}})
case "/open/video/video_push":
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-2"},
Title: "test",
Path: "/media/test2.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc2",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
if _, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4); !errors.Is(err, ErrCloud115TranscodePending) {
t.Fatalf("resolve pending err = %v, want ErrCloud115TranscodePending", err)
}
}
func TestMediaPlaybackProvider(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"115", "/api/strm/play/cloud115/video.mkv?acct=1&pickcode=pc", model.StrmProvider115},
{"openlist", "/api/strm/play/openlist/video.mkv?acct=1&ref=/a.mkv", model.StrmProviderOpenList},
{"local", "/media/local.mkv", model.StrmProviderLocal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
media := &model.Media{Path: tc.raw}
if tc.name != "local" {
media.STRMURL = tc.raw
}
got := MediaPlaybackProvider(media)
if got != tc.want {
t.Fatalf("provider = %q, want %q", got, tc.want)
}
})
}
}
+54 -9
View File
@@ -49,6 +49,12 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
const (
minPlayerPlaybackRate = 0.5
maxPlayerPlaybackRate = 3.0
defaultPlayerPlaybackRate = 1.0
)
// danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source.
// A package var (not a const) so tests can point it at a local server.
@@ -63,6 +69,10 @@ type DanmakuRenderConfig struct {
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
@@ -89,6 +99,9 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
type danmakuUserContextKey struct{}
@@ -345,10 +358,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -399,7 +413,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
return DanmakuRenderConfig{
Enabled: true,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
}
}
opacity := user.DanmakuOpacity
if opacity < 0.1 || opacity > 1 {
@@ -417,6 +438,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if volume < 0 || volume > 1 {
volume = 1
}
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource),
@@ -425,6 +450,8 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
@@ -495,6 +522,20 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_volume"] = *patch.Volume
}
if patch.PlaybackRate != nil {
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
return DanmakuRenderConfig{}, fmt.Errorf(
"%w: playback_rate must be between %.2f and %.2f",
ErrInvalidDanmakuSettings,
minPlayerPlaybackRate,
maxPlayerPlaybackRate,
)
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
@@ -1423,7 +1464,9 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary,
}
lastErr = err
if i < len(bases)-1 {
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(base)),
zap.Error(redactSensitiveError(err)))
}
}
return "", "auto", lastErr
@@ -1439,7 +1482,9 @@ func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configu
if err == nil {
return candidates, configured, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
s.log.Warn("danmaku search failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(configured)),
zap.Error(redactSensitiveError(err)))
}
candidates, err := s.searchCandidates(ctx, official, name, episode)
return candidates, official, err
@@ -1573,9 +1618,9 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
if err != nil {
s.log.Debug("danmaku configured lookup failed",
zap.String("source", configured),
zap.String("source", redactSensitiveURL(configured)),
zap.String("anime", m.AnimeTitle),
zap.Error(err))
zap.Error(redactSensitiveError(err)))
return nil, false
}
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
+2
View File
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
user.DanmakuFontSize = 30
user.DanmakuArea = 0.7
user.PlayerVolume = 0.42
user.PlayerPlaybackRate = 1.25
require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume)
require.Equal(t, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
+10 -1
View File
@@ -69,7 +69,8 @@ type EmbyService struct {
// latestFlight collapses the homepage stampede: clients request Latest
// for every library at once, and a shared expiry used to rebuild each
// library in parallel.
latestFlight singleflight.Group
latestFlight singleflight.Group
latestRefresh sync.Map
tmdb *TMDbProvider
adult *AdultProvider
@@ -140,6 +141,10 @@ type ItemsParams struct {
SortOrder string
Limit int
StartIndex int
// SeasonIndex 对应客户端的 Season / SeasonIndex 查询参数(季序号,0 为特别篇)。
// nil 表示不按季过滤;非 nil 时只返回该季的剧集。客户端普遍用季序号而不是
// 虚拟季 ID 请求剧集,缺了它 /Shows/{id}/Episodes?Season=N 会返回整部剧。
SeasonIndex *int
}
const (
@@ -204,6 +209,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
+10
View File
@@ -47,10 +47,20 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
p.SortOrder,
strconv.Itoa(p.StartIndex),
strconv.Itoa(p.Limit),
formatSeasonIndexCacheKeyPart(p.SeasonIndex),
}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// formatSeasonIndexCacheKeyPart keeps "no season filter" distinct from "season 0"
// so a client's Season=0 (specials) request never reuses an unfiltered response.
func formatSeasonIndexCacheKeyPart(seasonIndex *int) string {
if seasonIndex == nil {
return "season:*"
}
return "season:" + strconv.Itoa(*seasonIndex)
}
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
+66 -23
View File
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
userData, _ := out["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
out["UserData"] = userData
}
userData["IsFavorite"] = true
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -130,11 +122,27 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
limit = 20
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
if items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey); ok {
if stale {
e.refreshLatestItemsAsync(cacheKey, userID, parentID, limit)
}
return items, nil
}
value, err := e.loadLatestItemsCached(ctx, userID, parentID, limit)
if err != nil {
return nil, err
}
if value.Items == nil {
return []map[string]any{}, nil
}
return value.Items, nil
}
func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
loadCtx := context.WithoutCancel(ctx)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
return embyLatestCacheValue{Items: items}, nil
@@ -144,34 +152,55 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
return nil, err
}
if e.cache != nil {
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
}
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
cached, _ := v.(embyLatestCacheValue)
if cached.Items == nil {
return []map[string]any{}, nil
return cached, nil
}
func (e *EmbyService) refreshLatestItemsAsync(cacheKey, userID, parentID string, limit int) {
if e == nil || e.cache == nil {
return
}
return cached.Items, nil
if _, loaded := e.latestRefresh.LoadOrStore(cacheKey, struct{}{}); loaded {
return
}
go func() {
defer e.latestRefresh.Delete(cacheKey)
if _, err := e.loadLatestItemsCached(context.Background(), userID, parentID, limit); err != nil && e.log != nil {
e.log.Debug("background refresh of latest items failed",
zap.String("parent_id", parentID),
zap.Error(redactSensitiveError(err)))
}
}()
}
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey)
return items, ok && !stale
}
func (e *EmbyService) cachedLatestItemsWithStale(ctx context.Context, cacheKey string) ([]map[string]any, bool, bool) {
if e == nil || e.cache == nil {
return nil, false
return nil, false, false
}
var cached embyLatestCacheValue
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
return nil, false
found, stale := e.cache.GetJSONStale(ctx, cacheKey, &cached)
if !found {
return nil, false, false
}
e.rememberArtworkRefs(cached.Artwork)
if cached.Items == nil {
return []map[string]any{}, true
return []map[string]any{}, stale, true
}
return cached.Items, true
return cached.Items, stale, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
@@ -181,7 +210,7 @@ func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID stri
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
@@ -429,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
favSet := map[string]bool{}
if len(mediaIDs) > 0 {
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
Find(&favs).Error; err != nil {
return nil, err
}
for _, fav := range favs {
favSet[fav.MediaID] = true
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
@@ -461,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
@@ -522,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item
}
}
+21 -6
View File
@@ -121,16 +121,31 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) {
rows = e.filterMediaRowsForUser(ctx, rows, p.UserID)
if p.SearchTerm != "" {
filtered := rows[:0]
needle := strings.ToLower(p.SearchTerm)
for _, row := range rows {
if strings.Contains(strings.ToLower(row.Title), needle) || strings.Contains(strings.ToLower(row.OriginalName), needle) {
filtered = append(filtered, row)
// rows 可能来自 series 分组的内存 memo(embySeriesGroup.Episodes)。过滤必须
// 分配新切片:就地复用 rows[:0] 会覆写 memo 里的元素,让后续请求看到被前一次
// 过滤污染的剧集列表(例如按季筛选一次之后,特别篇就从缓存分组里消失了)。
if p.SeasonIndex != nil {
filtered := make([]model.Media, 0, len(rows))
for i := range rows {
if embyRowMatchesSeasonIndex(&rows[i], p.SeasonIndex) {
filtered = append(filtered, rows[i])
}
}
rows = filtered
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
filtered := make([]model.Media, 0, len(rows))
for i := range rows {
if strings.Contains(strings.ToLower(rows[i].Title), needle) || strings.Contains(strings.ToLower(rows[i].OriginalName), needle) {
filtered = append(filtered, rows[i])
}
}
rows = filtered
}
// sort.SliceStable 同样会就地重排:先拷贝一份,避免把 memo 分组里的剧集顺序
// 按每次请求的分页/筛选结果固定下来。
rows = append([]model.Media(nil), rows...)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].SeasonNum != rows[j].SeasonNum {
return rows[i].SeasonNum < rows[j].SeasonNum
+191 -9
View File
@@ -510,12 +510,12 @@ func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.S
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MeBox", Device="MeBox-Federated", DeviceId="mebox-federated", Version="1.0"`)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("连接远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("连接远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
var login struct {
AccessToken string `json:"AccessToken"`
@@ -599,8 +599,16 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
}
if lastErr != nil {
if r.log != nil && acct != nil {
r.log.Warn("remote emby request failed",
zap.String("account", acct.Name), zap.String("path", path), zap.Error(lastErr))
fields := []zap.Field{
zap.String("account", acct.Name),
zap.String("path", path),
zap.Error(redactSensitiveError(lastErr)),
}
if errors.Is(lastErr, context.Canceled) {
r.log.Debug("remote emby request canceled", fields...)
} else {
r.log.Warn("remote emby request failed", fields...)
}
}
return lastErr
}
@@ -629,7 +637,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
@@ -656,7 +664,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
continue
}
if resp.StatusCode >= 300 {
return fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
if out == nil {
return nil
@@ -852,6 +860,180 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
return out, nil
}
// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series;
// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID
// 后无法在 Series 卡片列表中找到对应条目。
func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if remoteCollectionLooksEpisodic(mount.CollectionType) {
return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit)
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
if remoteItemsContainEpisodes(items) {
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
return items, nil
}
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
// 对应 Series。
//
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
// Emby 客户端列剧集方式一致。
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 多取一些以便滤掉中间容器后仍够 limit。
q.Set("Limit", strconv.Itoa(limit*4))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
filtered := make([]map[string]any, 0, limit)
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
filtered = append(filtered, it)
if len(filtered) >= limit {
break
}
}
if len(filtered) > 0 {
RewriteEmbyRemoteIDs(filtered, mount.ID)
return filtered, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
func remoteCollectionLooksEpisodic(collectionType string) bool {
switch strings.ToLower(strings.TrimSpace(collectionType)) {
case "tvshows", "tv":
return true
default:
return false
}
}
func remoteItemsContainEpisodes(items []map[string]any) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
return true
}
}
return false
}
func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 20
}
out := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if len(out) >= limit {
break
}
if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
id := strings.TrimSpace(remoteItemString(item, "Id"))
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, item)
continue
}
seriesID := remoteItemSeriesID(item, mount)
if seriesID == "" {
continue
}
if _, exists := seen[seriesID]; exists {
continue
}
seen[seriesID] = struct{}{}
out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID))
}
return out
}
func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string {
seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId"))
if seriesID == "" {
return ""
}
if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok {
if mount == nil {
return rawID
}
return EncodeEmbyRemoteID(mount.ID, rawID)
}
if mount == nil {
return seriesID
}
return EncodeEmbyRemoteID(mount.ID, seriesID)
}
func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any {
name := strings.TrimSpace(remoteItemString(episode, "SeriesName"))
if name == "" {
name = strings.TrimSpace(remoteItemString(episode, "Name"))
}
out := map[string]any{
"Id": seriesID,
"Name": name,
"Type": "Series",
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
}
if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" {
out["ImageTags"] = map[string]any{"Primary": tag}
}
year := remoteItemInt(episode, "SeriesProductionYear")
if year == 0 {
year = remoteItemInt(episode, "SeriesYear")
}
if year == 0 {
year = remoteItemInt(episode, "ProductionYear")
}
if year > 0 {
out["ProductionYear"] = year
}
for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} {
if value, ok := episode[key]; ok {
out[key] = value
}
}
return out
}
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
@@ -1121,7 +1303,7 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
if value := resp.Header.Get(header); value != "" {
@@ -1281,12 +1463,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
return nil
}
+74 -80
View File
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
@@ -509,19 +509,24 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
return items, total, nil
}
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
// 直接解析失败,因此按 200 条翻页拉全量。
const remoteSeriesPageSize = 200
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
// 时填充。
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
@@ -529,19 +534,17 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
q.Set("Limit", "200")
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
cards := make([]SeriesCard, 0)
for startIndex := 0; ; startIndex += 200 {
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
q.Set("StartIndex", strconv.Itoa(startIndex))
body.Items = nil
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
@@ -551,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
break
}
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
@@ -565,9 +572,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: true,
LastAddedAt: lastAdded,
})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
break
}
}
@@ -577,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
return cards, nil
}
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
if isEmbyGenericContainer(name) {
return true
}
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
return base != "" && isEmbyGenericContainer(base)
}
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
@@ -589,52 +611,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
return cached, nil
}
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
cards := make([]SeriesCard, 0, len(items))
for _, it := range items {
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
var lastAdded *time.Time
if !m.UpdatedAt.IsZero() {
t := m.UpdatedAt
@@ -647,7 +631,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
if count == 0 {
count = 1
}
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: isSeries,
LastAddedAt: lastAdded,
})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
+448 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -120,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
w.WriteHeader(http.StatusBadRequest)
return
}
if q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -230,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -465,6 +476,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
if cards[0].Count != 12 {
t.Fatalf("expected Count 12, got %d", cards[0].Count)
}
if !cards[0].IsSeries {
t.Fatalf("expected remote latest card to be marked as series")
}
if cards[0].Rep.PosterURL == "" {
t.Fatalf("expected PosterURL not empty")
}
@@ -476,6 +490,439 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
}
}
func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) {
var seriesQueryCalled atomic.Bool
var latestCalled atomic.Bool
var requestedSeriesType atomic.Value
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
seriesQueryCalled.Store(true)
requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes"))
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
latestCalled.Store(true)
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-2",
"Name": "第二集",
"Type": "Episode",
"SeriesId": "series-b",
"SeriesName": "剧集 B",
"SeriesProductionYear": 2025,
"SeriesPrimaryImageTag": "poster-b",
},
{
"Id": "episode-1",
"Name": "第一集",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
"SeriesProductionYear": 2024,
},
{
"Id": "episode-1b",
"Name": "第一集下",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tv-fallback"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tv-fallback"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if !seriesQueryCalled.Load() || !latestCalled.Load() {
t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load())
}
if got, _ := requestedSeriesType.Load().(string); got != "Series" {
t.Fatalf("series query IncludeItemTypes = %q, want Series", got)
}
if len(cards) != 2 {
t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards)
}
wantKeys := []string{
EncodeEmbyRemoteID(mount.ID, "series-b"),
EncodeEmbyRemoteID(mount.ID, "series-a"),
}
for i, want := range wantKeys {
if cards[i].Key != want {
t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want)
}
if !cards[i].IsSeries {
t.Fatalf("card[%d] should be marked as series", i)
}
}
}
func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-10",
"Name": "第十集",
"Type": "Episode",
"SeriesId": "series-10",
"SeriesName": "远程剧集",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t,
&model.Setting{},
&model.User{},
&model.Favorite{},
&model.PlaybackHistory{},
&model.StrmAccount{},
&model.EmbyMount{},
)
repos := repository.New(db)
remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-emby-latest"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-emby-latest"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10)
if err != nil {
t.Fatalf("LatestItems failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 series item, got %d: %#v", len(items), items)
}
if items[0]["Type"] != "Series" {
t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"])
}
wantID := EncodeEmbyRemoteID(mount.ID, "series-10")
if items[0]["Id"] != wantID {
t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID)
}
}
// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集
// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。
func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
const totalSeries = remoteSeriesPageSize*2 + 50
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
if query.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
limit, _ := strconv.Atoi(query.Get("Limit"))
requests.Add(1)
items := make([]map[string]any, 0, limit)
for i := startIndex; i < totalSeries && len(items) < limit; i++ {
items = append(items, map[string]any{
"Id": "series-" + strconv.Itoa(i),
"Name": "剧集 " + strconv.Itoa(i),
"Type": "Series",
"RecursiveItemCount": 12,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": items,
"TotalRecordCount": totalSeries,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-series-pages"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-series-pages"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if len(cards) != totalSeries {
t.Fatalf("expected all %d series, got %d", totalSeries, len(cards))
}
if got := requests.Load(); got != 3 {
t.Fatalf("expected 3 paged requests, got %d", got)
}
lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1))
if cards[len(cards)-1].Key != lastKey {
t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey)
}
}
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
var sawRecursive atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
http.NotFound(w, r)
return
}
if q.Get("Recursive") == "true" {
sawRecursive.Store(true)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 5,
"Items": []map[string]any{
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-recursive"},
Name: "nijigem",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-recursive"},
AccountID: acct.ID,
RemoteViewID: "view-2023",
RemoteViewName: "2023前 动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if !sawRecursive.Load() {
t.Fatal("expected Recursive=true on remote Items request")
}
if len(cards) != 3 {
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
}
for _, card := range cards {
if strings.EqualFold(card.Rep.Title, "anime") {
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
}
}
}
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
// 媒体库页找不到目标剧集,只能退回整库列表。
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
var seriesQueryCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
seriesQueryCalls.Add(1)
parentID := r.URL.Query().Get("ParentId")
if parentID == "series-latest" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
"Id": "series-latest",
"Name": "刚刚更新",
"Type": "Series",
"RecursiveItemCount": 24,
},
},
"TotalRecordCount": 1,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-latest-key"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-latest-key"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if len(latestCards) != 1 || !latestCards[0].IsSeries {
t.Fatalf("expected 1 series latest card, got %#v", latestCards)
}
seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if seriesQueryCalls.Load() < 2 {
t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load())
}
found := false
for _, card := range seriesCards {
if card.Key == latestCards[0].Key {
found = true
break
}
}
if !found {
t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards)
}
}
func TestRemoteLatestFields(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+231
View File
@@ -0,0 +1,231 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// seasonIndexFixture builds one series covering every season-numbering class a
// client can ask for: two regular seasons, a generic specials bucket (season 0)
// and an OVA folder (negative season).
func seasonIndexFixture(t *testing.T) (*EmbyService, string) {
t.Helper()
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `F:\media\剧集`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
rows := []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 01\權力的遊戲 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "s1e2"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 01\權力的遊戲 - S01E02.mkv`,
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "s2e1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Season 02\權力的遊戲 - S02E01.mkv`,
SeasonNum: 2,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "spec1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\Specials\權力的遊戲 - S00E01.mkv`,
SeasonNum: 0,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "ova1"},
LibraryID: lib.ID,
Title: "權力的遊戲",
Path: `F:\media\剧集\權力的遊戲 (2011)\OVA\權力的遊戲 - OVA.mkv`,
SeasonNum: 0,
EpisodeNum: 1,
},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
items, err := svc.Items(t.Context(), ItemsParams{
ParentID: lib.ID,
IncludeItemTypes: []string{"Series"},
Limit: 10,
})
if err != nil {
t.Fatalf("series items: %v", err)
}
cards := items["Items"].([]map[string]any)
if len(cards) != 1 {
t.Fatalf("want one series card, got %#v", cards)
}
return svc, cards[0]["Id"].(string)
}
func episodeIDs(t *testing.T, out map[string]any) []string {
t.Helper()
items, ok := out["Items"].([]map[string]any)
if !ok {
t.Fatalf("Items has unexpected type: %#v", out["Items"])
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item["Id"].(string))
}
return ids
}
// itemsTotal reads TotalRecordCount, which is int on the episode path and int64
// on the empty-envelope path.
func itemsTotal(t *testing.T, out map[string]any) int {
t.Helper()
switch v := out["TotalRecordCount"].(type) {
case int:
return v
case int64:
return int(v)
default:
t.Fatalf("TotalRecordCount has unexpected type: %#v", out["TotalRecordCount"])
return 0
}
}
func assertEpisodeIDs(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("episode ids = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("episode ids = %#v, want %#v", got, want)
}
}
}
// TestEmbyEpisodeItemsFilterBySeasonIndex is the regression guard for clients
// that scope episodes by season number (Season=N) instead of the virtual season
// id: without the filter every season was returned for any requested season.
func TestEmbyEpisodeItemsFilterBySeasonIndex(t *testing.T) {
svc, seriesID := seasonIndexFixture(t)
cases := []struct {
name string
season *int
wantIDs []string
wantTotal int
}{
{
name: "unfiltered returns every episode",
season: nil,
// episodeItems sorts by the stored season/episode numbers, so the two
// specials (both stored as season 0) come first in creation order.
wantIDs: []string{"spec1", "ova1", "s1e1", "s1e2", "s2e1"},
wantTotal: 5,
},
{name: "season 1", season: intPtr(1), wantIDs: []string{"s1e1", "s1e2"}, wantTotal: 2},
{name: "season 2", season: intPtr(2), wantIDs: []string{"s2e1"}, wantTotal: 1},
{name: "season 0 is the specials bucket", season: intPtr(0), wantIDs: []string{"spec1"}, wantTotal: 1},
{name: "OVA season", season: intPtr(embySeasonOVA), wantIDs: []string{"ova1"}, wantTotal: 1},
{name: "unknown season is empty", season: intPtr(9), wantIDs: []string{}, wantTotal: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := svc.Items(t.Context(), ItemsParams{
ParentID: seriesID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
SeasonIndex: tc.season,
})
if err != nil {
t.Fatalf("items: %v", err)
}
assertEpisodeIDs(t, episodeIDs(t, out), tc.wantIDs)
if total := itemsTotal(t, out); total != tc.wantTotal {
t.Fatalf("TotalRecordCount = %d, want %d", total, tc.wantTotal)
}
})
}
}
// TestEmbySeasonIndexFilterKeepsSeasonIdsWorking pins the pre-existing
// SeasonId behaviour: resolving through the virtual season id must still return
// exactly that season.
func TestEmbySeasonIndexFilterKeepsSeasonIdsWorking(t *testing.T) {
svc, seriesID := seasonIndexFixture(t)
items, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50})
if err != nil {
t.Fatalf("seasons: %v", err)
}
seasons := items["Items"].([]map[string]any)
var seasonTwoID string
for _, season := range seasons {
if season["IndexNumber"] == 2 {
seasonTwoID = season["Id"].(string)
}
}
if seasonTwoID == "" {
t.Fatalf("season 2 missing from payload: %#v", seasons)
}
out, err := svc.Items(t.Context(), ItemsParams{
ParentID: seasonTwoID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("season episodes: %v", err)
}
got := episodeIDs(t, out)
assertEpisodeIDs(t, got, []string{"s2e1"})
}
// TestEmbyItemsCacheKeySeparatesSeasonFilters guards against a cached unfiltered
// response being reused for a season-scoped request (and vice versa).
func TestEmbyItemsCacheKeySeparatesSeasonFilters(t *testing.T) {
svc := newTestEmbyService(t)
base := ItemsParams{ParentID: "series-1", IncludeItemTypes: []string{"Episode"}, Limit: 50}
unfiltered := svc.embyItemsCacheKey("items", base)
seasonZero := svc.embyItemsCacheKey("items", withSeasonIndex(base, 0))
seasonTwo := svc.embyItemsCacheKey("items", withSeasonIndex(base, 2))
if unfiltered == seasonZero {
t.Fatal("unfiltered and Season=0 keys must differ")
}
if unfiltered == seasonTwo {
t.Fatal("unfiltered and Season=2 keys must differ")
}
if seasonZero == seasonTwo {
t.Fatal("Season=0 and Season=2 keys must differ")
}
if again := svc.embyItemsCacheKey("items", withSeasonIndex(base, 2)); again != seasonTwo {
t.Fatal("cache key must be stable for the same season filter")
}
}
func withSeasonIndex(p ItemsParams, season int) ItemsParams {
p.SeasonIndex = intPtr(season)
return p
}
func intPtr(v int) *int { return &v }
+10
View File
@@ -154,6 +154,16 @@ func embySeasonNumForMedia(m *model.Media) int {
return m.SeasonNum
}
// embyRowMatchesSeasonIndex 判断某条剧集是否属于客户端请求的季序号。
// seasonIndex 为 nil 时不参与过滤。判定复用 embySeasonNumForMedia,保证与
// /Shows/{id}/Seasons 暴露的 IndexNumber 完全一致(含特别篇的负数分类)。
func embyRowMatchesSeasonIndex(m *model.Media, seasonIndex *int) bool {
if seasonIndex == nil {
return true
}
return embySeasonNumForMedia(m) == *seasonIndex
}
func embySeasonCandidates(seasonNum int) []int {
if seasonNum > 0 {
return []int{seasonNum}
+76 -42
View File
@@ -11,8 +11,8 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
return err
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if e.remote != nil && IsEmbyRemoteID(mediaID) {
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
return err
}
return nil
}
if !played {
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
return err
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
dur := int64(0)
if IsEmbyRemoteID(mediaID) {
dur = remoteItemDurationMs(ctx, e, mediaID)
} else {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur = int64(m.DurationSec) * 1000
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return err
}
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
return 0
}
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return 0
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
return 0
}
switch ticks := item["RunTimeTicks"].(type) {
case float64:
if ticks > 0 {
return int64(ticks) / 10_000
}
case int64:
if ticks > 0 {
return ticks / 10_000
}
case int:
if ticks > 0 {
return int64(ticks) / 10_000
}
}
return 0
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
return value
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
// mergeRemoteUserData overlays the current MeBox user's locally recorded
// playback and favourite state onto remote Emby payloads. Remote mounts share
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil {
return nil
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
}
for _, item := range items {
id, _ := item["Id"].(string)
userData, _ := item["UserData"].(map[string]any)
if h := byMediaID[id]; h != nil {
item["UserData"] = mergedRemoteUserData(userData, h)
userData, _ = item["UserData"].(map[string]any)
}
if favSet[id] {
if userData == nil {
userData = map[string]any{}
item["UserData"] = userData
}
userData["IsFavorite"] = true
}
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
}
return nil
}
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
}
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
favorite := false
if existing, ok := raw.(map[string]any); ok {
if v, ok := existing["IsFavorite"].(bool); ok {
favorite = v
}
}
return applyMeBoxUserData(raw, history, favorite)
}
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok {
for key, value := range existing {
userData[key] = value
switch key {
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
continue
default:
userData[key] = value
}
}
}
userData["IsFavorite"] = favorite
if history == nil {
userData["PlaybackPositionTicks"] = int64(0)
userData["Played"] = false
userData["PlayedPercentage"] = float64(0)
userData["PlayCount"] = 0
return userData
}
duration := history.DurationMs
position := history.PositionMs
percentage := float64(0)
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage
if history.Completed {
playCount := 0
switch value := userData["PlayCount"].(type) {
case int:
playCount = value
case int64:
playCount = int(value)
case float64:
playCount = int(value)
}
if playCount < 1 {
userData["PlayCount"] = 1
}
userData["PlayCount"] = 1
} else {
userData["PlayCount"] = 0
}
return userData
}
+97 -4
View File
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
preserve any
}{
{
name: "in-progress preserves remote fields",
name: "in-progress preserves non-user remote fields only",
raw: map[string]any{
"PlayCount": 2,
"Custom": "remote-value",
"PlayCount": 2,
"IsFavorite": true,
"PlaybackPositionTicks": int64(999),
"Custom": "remote-value",
},
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000,
played: false,
percent: 25,
count: 2,
count: 0,
preserve: "remote-value",
},
{
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
}
if out["IsFavorite"] != false && out["IsFavorite"] != true {
t.Fatalf("IsFavorite missing: %#v", out)
}
})
}
}
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
out := applyMeBoxUserData(map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(42_000_000),
"Played": true,
"PlayedPercentage": 80.0,
"PlayCount": 3,
"Key": "keep",
}, nil, false)
if out["IsFavorite"] != false {
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
}
if out["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
}
if out["Played"] != false {
t.Fatalf("Played = %#v, want false", out["Played"])
}
if out["PlayedPercentage"] != float64(0) {
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
}
if out["PlayCount"] != 0 {
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
}
if out["Key"] != "keep" {
t.Fatalf("Key = %#v, want keep", out["Key"])
}
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
if fav["IsFavorite"] != true {
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
}
}
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
t.Fatalf("expected 95%% progress to be completed")
}
}
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
svc := newTestEmbyService(t)
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
t.Fatalf("create alice: %v", err)
}
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
t.Fatalf("create bob: %v", err)
}
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
t.Fatalf("mark played: %v", err)
}
var aliceRows, bobRows int64
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
if aliceRows != 1 {
t.Fatalf("alice history rows = %d, want 1", aliceRows)
}
if bobRows != 0 {
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
}
payload := map[string]any{
"Id": remoteID,
"UserData": map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(50_000_000),
"Played": true,
},
}
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
t.Fatalf("merge for bob: %v", err)
}
bobData := payload["UserData"].(map[string]any)
if bobData["IsFavorite"] != false {
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
}
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("bob playback leaked: %#v", bobData)
}
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
t.Fatalf("merge for alice: %v", err)
}
aliceData := payload["UserData"].(map[string]any)
if aliceData["Played"] != true {
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
}
}
+5 -27
View File
@@ -10,21 +10,14 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// SyncUserFavorite keeps favourite state aligned across the local favourites table
// and the upstream remote Emby server for mounted items.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
// state is intentionally not proxied upstream.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
if repo == nil || userID == "" || mediaID == "" {
return errors.New("missing favourite sync inputs")
}
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
return err
}
if favorite || IsEmbyRemoteID(mediaID) {
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
return err
}
}
return nil
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
}
// IsUserFavorite reports whether the user has favourited mediaID locally.
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
if remote == nil || !IsEmbyRemoteID(mediaID) {
return nil
}
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
if !ok {
return nil
}
_, acct, err := remote.ResolveMount(ctx, mountID)
if err != nil {
return err
}
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
}
+9 -5
View File
@@ -180,18 +180,20 @@ func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string,
var lastErr error
for i, u := range urls {
if i > 0 && log != nil {
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", redactSensitiveURL(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))
log.Warn("ffmpeg 下载失败",
zap.String("url", redactSensitiveURL(u)),
zap.Error(redactSensitiveError(err)))
}
continue
}
return nil
}
return fmt.Errorf("所有下载源均失败:%v", lastErr)
return redactSensitiveError(fmt.Errorf("所有下载源均失败:%v", lastErr))
}
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
@@ -221,10 +223,12 @@ func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string)
return err
}
if n > 500<<20 {
return fmt.Errorf("归档文件过大(>500MB): %s", url)
return fmt.Errorf("归档文件过大(>500MB): %s", redactSensitiveURL(url))
}
if log != nil {
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
log.Info("ffmpeg 归档下载完成",
zap.String("url", redactSensitiveURL(url)),
zap.Int64("bytes", n))
}
return nil
}
+34
View File
@@ -27,8 +27,19 @@ type FFprobeService struct {
log *zap.Logger
mu sync.RWMutex
limiter chan struct{}
// availMu guards the short-lived availability cache used by Available().
availMu sync.Mutex
availCheckedAt time.Time
availValue bool
}
// ffprobeAvailabilityTTL bounds how long an Available() result is reused.
// Resolving a binary stats up to a couple dozen candidate paths, and the scanner
// consults availability per root while deciding whether to queue backfill
// probes. A short TTL keeps a freshly installed ffmpeg visible within seconds.
const ffprobeAvailabilityTTL = 30 * time.Second
// NewFFprobeService is the constructor.
func NewFFprobeService(cfg *config.Config, log *zap.Logger) *FFprobeService {
maxConcurrent := normalizeFFprobeMaxConcurrent(cfg.App.FFprobeMaxConcurrent)
@@ -54,6 +65,29 @@ func (f *FFprobeService) SetMaxConcurrent(n int) {
f.limiter = make(chan struct{}, normalizeFFprobeMaxConcurrent(n))
}
// Available reports whether a probe can actually run right now: either an
// ffprobe binary or the ffmpeg fallback must be resolvable. The scan path uses
// this to decide whether re-queueing probes for media that still lack technical
// metadata is worth doing — without a binary every probe would just fail.
func (f *FFprobeService) Available() bool {
if f == nil || f.cfg == nil {
return false
}
f.availMu.Lock()
defer f.availMu.Unlock()
if !f.availCheckedAt.IsZero() && time.Since(f.availCheckedAt) < ffprobeAvailabilityTTL {
return f.availValue
}
available := true
if _, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe"); err != nil {
_, ffmpegErr := resolveLocalExecutable(f.cfg.App.FFmpegPath, "ffmpeg")
available = ffmpegErr == nil
}
f.availValue = available
f.availCheckedAt = time.Now()
return available
}
// ProbeResult is the subset of ffprobe output consumed by the scanner.
type ProbeResult struct {
DurationSec int
+2 -2
View File
@@ -196,14 +196,14 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
return data, ctype, contentLength, nil
}
p.log.Warn("imageproxy: curl fallback failed", zap.String("host", host), zap.Error(err))
logImageFetchError(p.log, "imageproxy: curl fallback failed", host, "curl", err)
lastErr = err
}
p.markImageFetchFailed(failPath)
if lastErr == nil {
lastErr = errors.New("upstream image fetch failed")
}
return nil, "", "", lastErr
return nil, "", "", redactSensitiveError(lastErr)
}
type sharedRemoteImageResult struct {
+24 -8
View File
@@ -48,14 +48,14 @@ func (p *ImageProxy) canUseExternalImageFallback() bool {
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
return nil, "", "", errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host, raw)
resp, err := candidate.client.Do(req)
if err != nil {
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
return nil, "", "", err
}
defer resp.Body.Close()
@@ -65,7 +65,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err)))
if err == nil {
err = errors.New("upstream image body is empty")
}
@@ -79,6 +79,22 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
return data, ctype, resp.Header.Get("Content-Length"), nil
}
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
if log == nil || err == nil {
return
}
fields := []zap.Field{
zap.String("host", host),
zap.String("client", client),
zap.Error(redactSensitiveError(err)),
}
if errors.Is(err, context.Canceled) {
log.Debug(message, fields...)
return
}
log.Warn(message, fields...)
}
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
@@ -162,9 +178,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
"--header", "Cache-Control: no-cache",
"--header", "Pragma: no-cache",
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if cookie := remoteImageCookie(host); cookie != "" {
args = append(args, "--cookie", cookie)
}
@@ -188,9 +204,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
if waitErr != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return nil, "", "", errors.New(message)
return nil, "", "", redactSensitiveError(errors.New(message))
}
return nil, "", "", waitErr
return nil, "", "", redactSensitiveError(waitErr)
}
if len(data) == 0 {
return nil, "", "", errors.New("curl image body is empty")
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"errors"
"net/url"
"regexp"
"strings"
)
const sensitiveLogKeyPattern = `api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|authorization|client[_-]?secret|secret|signature|sig|x[_-]?emby[_-]?token|x[_-]?api[_-]?key|x[_-]?amz[_-]?signature|x[_-]?amz[_-]?credential|x[_-]?amz[_-]?security[_-]?token|awsaccesskeyid|session[_-]?id`
var (
sensitiveLogQueryRE = regexp.MustCompile(`(?i)([?&;])(` + sensitiveLogKeyPattern + `)=([^&#\s"']+)`)
sensitiveLogJSONRE = regexp.MustCompile(`(?i)("(?:` + sensitiveLogKeyPattern + `)"\s*:\s*")((?:[^"\\]|\\.)*)(")`)
sensitiveLogAssignmentRE = regexp.MustCompile(`(?im)((?:^|[\s,{])(?:` + sensitiveLogKeyPattern + `)\s*[:=]\s*(?:(?:bearer|basic)\s+)?)([^\s,;"'}\]]+)`)
)
// redactSensitiveURL removes credentials from URLs before they reach logs or
// error messages. Query values are replaced with REDACTED and URL userinfo
// passwords are removed.
func redactSensitiveURL(raw string) string {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Scheme == "" || u.Host == "" {
return redactSensitiveText(raw)
}
if u.User != nil {
if _, hasPassword := u.User.Password(); hasPassword {
u.User = url.UserPassword(u.User.Username(), "REDACTED")
}
}
query := u.Query()
changed := false
for key := range query {
if isSensitiveLogKey(key) {
query.Set(key, "REDACTED")
changed = true
}
}
if changed {
u.RawQuery = query.Encode()
}
return u.String()
}
func redactSensitiveText(value string) string {
value = sensitiveLogQueryRE.ReplaceAllString(value, `${1}${2}=REDACTED`)
value = sensitiveLogJSONRE.ReplaceAllString(value, `${1}REDACTED${3}`)
return sensitiveLogAssignmentRE.ReplaceAllString(value, `${1}REDACTED`)
}
type logRedactedError struct {
err error
}
func (e logRedactedError) Error() string {
return redactSensitiveText(e.err.Error())
}
func (e logRedactedError) Unwrap() error {
return e.err
}
// redactSensitiveError wraps an error with a redacted Error string while
// preserving errors.Is and errors.As behavior.
func redactSensitiveError(err error) error {
if err == nil {
return nil
}
var alreadyRedacted logRedactedError
if errors.As(err, &alreadyRedacted) {
return err
}
return logRedactedError{err: err}
}
func isSensitiveLogKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "_")
switch normalized {
case "api_key", "apikey", "access_token", "refresh_token", "id_token", "token",
"password", "passwd", "pwd", "authorization", "client_secret", "secret",
"signature", "sig", "x_emby_token", "x_api_key", "x_amz_signature",
"x_amz_credential", "x_amz_security_token", "awsaccesskeyid", "session_id":
return true
default:
return false
}
}
+52
View File
@@ -0,0 +1,52 @@
package service
import (
"context"
"errors"
"strings"
"testing"
)
func TestRedactSensitiveURL(t *testing.T) {
raw := "https://user:secret@media.example/emby/Items/1/Images/primary?api_key=abc123&X-Emby-Token=xyz&X-Amz-Signature=sig123&quality=90"
got := redactSensitiveURL(raw)
for _, secret := range []string{"secret", "abc123", "xyz", "sig123"} {
if strings.Contains(got, secret) {
t.Fatalf("redacted URL still contains %q: %s", secret, got)
}
}
if !strings.Contains(got, "quality=90") {
t.Fatalf("non-sensitive query value was removed: %s", got)
}
}
func TestRedactSensitiveTextCoversJSONAndHeaders(t *testing.T) {
raw := `{"AccessToken":"json-token","quality":"90"} Authorization: Bearer bearer-token; X-Emby-Token=header-token`
got := redactSensitiveText(raw)
for _, secret := range []string{"json-token", "bearer-token", "header-token"} {
if strings.Contains(got, secret) {
t.Fatalf("redacted text still contains %q: %s", secret, got)
}
}
if !strings.Contains(got, `"quality":"90"`) {
t.Fatalf("non-sensitive JSON value was removed: %s", got)
}
}
func TestRedactSensitiveErrorPreservesWrapping(t *testing.T) {
base := errors.New(`Get "https://media.example/item?token=secret&quality=90": context canceled`)
err := redactSensitiveError(context.Canceled)
if !errors.Is(err, context.Canceled) {
t.Fatal("redacted error lost context.Canceled")
}
if strings.Contains(err.Error(), "secret") {
t.Fatalf("redacted error leaked token: %s", err.Error())
}
redactedBase := redactSensitiveError(base)
if strings.Contains(redactedBase.Error(), "secret") {
t.Fatalf("redacted base error leaked token: %s", redactedBase.Error())
}
if !errors.Is(redactedBase, base) {
t.Fatal("redacted error lost original error identity")
}
}
+2 -2
View File
@@ -199,7 +199,7 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("query") != "inception" {
if !strings.EqualFold(r.URL.Query().Get("query"), "inception") {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
@@ -248,7 +248,7 @@ 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[len(queries)-1] != "inception" {
if len(queries) < 2 || queries[0] != "00000" || !strings.EqualFold(queries[len(queries)-1], "inception") {
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
}
}
+23 -14
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"sort"
"strings"
"time"
@@ -179,9 +178,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
narrowed := false
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
if !hasAdultCode {
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
@@ -190,23 +201,21 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
}
}
s.attachLibraryMetadata(ctx, candidates)
// 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠,
// 与列表页的分组结果保持一致。
vouched := adultCodesVouchedByNSFW(candidates)
if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" {
key = vouchKey
}
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
if mediaVersionGroupKey(row) == key {
if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key {
matched = append(matched, row)
}
}
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
sortMediaVersionsForDisplay(matched)
return matched, nil
}
+3
View File
@@ -33,6 +33,7 @@ type SeriesCard struct {
Rep model.Media `json:"rep"`
LinkMedia model.Media `json:"linkMedia"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -45,6 +46,7 @@ type SeriesCardView struct {
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
IsSeries: card.IsSeries,
LastAddedAt: card.LastAddedAt,
}
}
+4 -2
View File
@@ -70,7 +70,9 @@ func SortSeriesCards(cards []SeriesCard, field, order string, history map[string
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareSeriesByField(out[i], out[j], spec, history)
if cmp == 0 {
// updated_at 表示最后一集添加日期。远程卡片可能没有该字段,此时
// 保持调用方给定的顺序,不要用 ID 再次打乱。
if cmp == 0 && spec.Field != "updated_at" {
cmp = compareID(out[i].Key, out[j].Key)
}
return cmp < 0
@@ -173,7 +175,7 @@ func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
return *card.LastAddedAt, true
}
return mediaUpdatedTime(card.Rep)
return time.Time{}, false
}
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
+43
View File
@@ -2,6 +2,7 @@ package service
import (
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
@@ -40,3 +41,45 @@ func TestSortSeriesCardsTitleAsc(t *testing.T) {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtDoesNotFallbackToMediaDate(t *testing.T) {
olderMediaDate := time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)
newerMediaDate := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{
Key: "remote-first",
Rep: model.Media{Base: model.Base{
ID: "remote-first",
CreatedAt: olderMediaDate,
UpdatedAt: olderMediaDate,
}},
},
{
Key: "remote-second",
Rep: model.Media{Base: model.Base{
ID: "remote-second",
CreatedAt: newerMediaDate,
UpdatedAt: newerMediaDate,
}},
},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "remote-first" || got[1].Key != "remote-second" {
t.Fatalf("series order = %q, %q, want remote-provided order", got[0].Key, got[1].Key)
}
}
func TestSortSeriesCardsUpdatedAtSortsKnownLastEpisodeDates(t *testing.T) {
older := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
newer := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
cards := []SeriesCard{
{Key: "older", LastAddedAt: &older},
{Key: "newer", LastAddedAt: &newer},
}
got := SortSeriesCards(cards, "updated_at", "desc", nil)
if got[0].Key != "newer" || got[1].Key != "older" {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
+201 -25
View File
@@ -3,7 +3,9 @@ package service
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/model"
@@ -75,35 +77,33 @@ func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
}
// 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目:
// 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍
// pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。
adultCodes := adultCodesVouchedByNSFW(items)
type group struct {
key string
primary model.Media
rows []model.Media
key string
rows []model.Media
}
groups := make([]group, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := mediaVersionGroupKey(item)
key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes)
if key == "" {
groups = append(groups, group{primary: item, rows: []model.Media{item}})
groups = append(groups, group{rows: []model.Media{item}})
continue
}
if idx, ok := byKey[key]; ok {
groups[idx].rows = append(groups[idx].rows, item)
if betterMediaVersion(item, groups[idx].primary) {
groups[idx].primary = item
}
continue
}
byKey[key] = len(groups)
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
groups = append(groups, group{key: key, rows: []model.Media{item}})
}
out := make([]MediaItem, 0, len(groups))
for _, g := range groups {
sort.SliceStable(g.rows, func(i, j int) bool {
return betterMediaVersion(g.rows[i], g.rows[j])
})
item := MediaItem{Media: g.primary}
sortMediaVersionsForDisplay(g.rows)
item := MediaItem{Media: g.rows[0]}
if len(g.rows) > 1 {
item.Versions = g.rows
}
@@ -139,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
return grouped
}
func mediaVersionGroupKey(m model.Media) string {
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
// mediaVersionLibraryKey 返回版本身份里使用的库标识。
func mediaVersionLibraryKey(m model.Media) string {
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
return libKey
}
// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。
func adultCodesVouchedByNSFW(items []model.Media) map[string]bool {
codes := map[string]bool{}
for _, item := range items {
if !item.NSFW {
continue
}
if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" {
codes[code] = true
continue
}
if code := mediaAdultGroupCode(item); code != "" {
codes[code] = true
}
}
return codes
}
// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片
// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。
func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string {
if key := adultVouchedGroupKey(m, vouched); key != "" {
return key
}
return mediaVersionGroupKey(m)
}
// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键;
// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。
func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
if len(vouched) == 0 || m.NSFW {
return ""
}
code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path))
if code == "" || !vouched[code] {
return ""
}
libKey := mediaVersionLibraryKey(m)
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
libKey := mediaVersionLibraryKey(m)
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
@@ -193,6 +242,18 @@ func mediaVersionGroupKey(m model.Media) string {
return mediaVersionStemGroupKey(m, libKey)
}
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
// 来源无关,因此这里统一用番号做版本身份。
if code := mediaAdultGroupCode(m); code != "" {
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
switch {
case m.TMDbID > 0:
if libKey != "" {
@@ -267,6 +328,44 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year)
}
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
//
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
// 这类文件名不会被当成番号。
func mediaAdultGroupCode(m model.Media) string {
if !m.NSFW {
return ""
}
code := firstText(
normalizeAdultCode(m.OriginalName),
AdultCodeFromMediaPath(m.Path),
normalizeAdultCode(m.Title),
)
if code == "" {
return ""
}
return canonicalAdultGroupCode(code)
}
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
// (如 FC2-PPV-4701981)。
func canonicalAdultGroupCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
prefix, digits, ok := strings.Cut(code, "-")
if !ok {
return code
}
value, err := strconv.Atoi(digits)
if err != nil {
return code
}
return prefix + "-" + strconv.Itoa(value)
}
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
@@ -300,8 +399,11 @@ func mediaVersionFileStem(name string) string {
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
strings.TrimSpace(m.STRMURL) != ""
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
@@ -317,17 +419,91 @@ func MediaVersionLabel(m model.Media) string {
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
if m.SizeBytes > 0 && !placeholderSize {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
return cleaned
}
return base
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
func mediaVersionLabelIndistinct(parts []string) bool {
if len(parts) == 0 {
return true
}
for _, part := range parts {
upper := strings.ToUpper(strings.TrimSpace(part))
if upper == "" || upper == "云端直链" || upper == "STRM" {
continue
}
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
continue
}
return false
}
return true
}
func cleanMediaVersionFilename(path string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if base == "" || base == "." {
return ""
}
stem := mediaFileStem(base)
stem = strings.ReplaceAll(stem, "_", " ")
stem = strings.ReplaceAll(stem, ".", " ")
return strings.Join(strings.Fields(stem), " ")
}
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
func mediaPartNumber(path string) (int, bool) {
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
if stem == "" {
return 0, false
}
lower := strings.ToLower(stem)
for _, re := range mediaPartNumberPatterns {
if m := re.FindStringSubmatch(lower); len(m) == 2 {
n, err := strconv.Atoi(m[1])
if err == nil && n > 0 {
return n, true
}
}
}
return 0, false
}
var mediaPartNumberPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`[-_](\d{1,2})$`),
}
func sortMediaVersionsForDisplay(rows []model.Media) {
sort.SliceStable(rows, func(i, j int) bool {
return compareMediaVersionsForDisplay(rows[i], rows[j])
})
}
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
func compareMediaVersionsForDisplay(a, b model.Media) bool {
pa, oka := mediaPartNumber(a.Path)
pb, okb := mediaPartNumber(b.Path)
if oka && okb {
if pa != pb {
return pa < pb
}
} else if oka != okb {
return oka
}
return betterMediaVersion(a, b)
}
func mediaVersionContainerFromPath(path, strmURL string) string {
@@ -0,0 +1,66 @@
package service
import (
"strconv"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。
// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡
// (一张只有第 4 个,另一张是其余 6 个)。
func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) {
const libID = "lib-1"
const dir = "/media/云下载/sivr-270/"
parts := func(nums ...int) []model.Media {
out := make([]model.Media, 0, len(nums))
for _, n := range nums {
out = append(out, model.Media{
LibraryID: libID,
Title: "sivr 270",
Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm",
Container: "strm",
})
}
return out
}
items := parts(1, 2, 3)
items = append(items, model.Media{
LibraryID: libID,
Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース",
OriginalName: "SIVR-270",
Path: dir + "sivr-270-4.strm",
NSFW: true,
ScrapeStatus: "matched",
Container: "strm",
})
items = append(items, parts(5, 6, 7)...)
grouped := GroupMediaVersions(items)
if len(grouped) != 1 {
paths := make([]string, 0, len(grouped))
for _, g := range grouped {
paths = append(paths, g.Path)
}
t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths)
}
if len(grouped[0].Versions) != 7 {
t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions))
}
}
// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠,
// 否则普通媒体也可能被误并进同一张卡。
func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) {
items := []model.Media{
{LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"},
{LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"},
}
grouped := GroupMediaVersions(items)
if len(grouped) != 2 {
t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped))
}
}
+295
View File
@@ -442,3 +442,298 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
t.Fatalf("unexpected label %q", label)
}
}
func TestMediaPartNumberFromPath(t *testing.T) {
cases := []struct {
path string
want int
ok bool
}{
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
{"/media/Movies/Movie.2024.mkv", 0, false},
}
for _, tc := range cases {
got, ok := mediaPartNumber(tc.path)
if ok != tc.ok || got != tc.want {
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
}
}
}
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "p3"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
SizeBytes: 300,
},
{
Base: model.Base{ID: "p1"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
SizeBytes: 100,
},
{
Base: model.Base{ID: "p2"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
SizeBytes: 200,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
t.Fatalf("grouped = %#v", grouped)
}
want := []string{"p1", "p2", "p3"}
for i, id := range want {
if grouped[0].Versions[i].ID != id {
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
}
}
if grouped[0].ID != "p1" {
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
}
}
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
label := MediaVersionLabel(model.Media{
Title: "SIVR-270-【VR】河北彩花",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
SizeBytes: 157,
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
})
if !strings.Contains(label, "sivr-270-1") {
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
}
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
t.Fatalf("should not keep generic-only label %q", label)
}
}
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
online := model.Media{
Base: model.Base{ID: "sivr-270-2"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromNFO := model.Media{
Base: model.Base{ID: "sivr-270-3"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{online, fromNFO})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Path != online.Path {
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
}
}
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "sivr-270"},
LibraryID: "cloud",
Title: "SIVR-270 作品",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
},
{
Base: model.Base{ID: "sivr-271"},
LibraryID: "cloud",
Title: "SIVR-271 作品",
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
Year: 2023,
NSFW: true,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 2 {
t.Fatalf("different adult codes must stay separate: %#v", grouped)
}
}
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
scraped := model.Media{
Base: model.Base{ID: "ipvr-192-part2"},
LibraryID: "cloud",
Title: "IPVR-192-相沢みなみ",
OriginalName: "IPVR-192",
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromPath := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "fbzip com@ipvr00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
}
}
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
part1 := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
OriginalName: "IPVR-00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
part2 := part1
part2.ID = "ipvr-00192-part2"
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
part2.DoubanID = "ipvr00192"
part2.TheTVDBID = "JAV321"
part2.SizeBytes = 200
grouped := groupMediaVersions([]model.Media{part1, part2})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
}
}
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
},
{
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
if err != nil {
t.Fatal(err)
}
if item == nil || len(item.Versions) != 2 {
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
t.Fatalf("versions = %#v, want both parts", item)
}
}
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
cases := []struct {
name string
m model.Media
want string
}{
{
name: "普通影片名里的字母+数字不当番号",
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
want: "",
},
{
name: "未识别成人的条目即使文件名像番号也不折叠",
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "",
},
{
name: "已标记 NSFW 时按番号分组",
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "SIVR-270",
},
{
name: "站点前缀里带补零的番号会归一",
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
want: "IPVR-192",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaAdultGroupCode(tc.m); got != tc.want {
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
}
})
}
}
+85 -15
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"go.uber.org/zap"
@@ -48,9 +49,13 @@ type movieNFO struct {
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
}
type episodeNFO struct {
@@ -71,7 +76,10 @@ type episodeNFO struct {
}
// ExportOne writes a movie.nfo file next to the media file. Existing files
// are overwritten so a re-scrape always reflects the latest metadata.
// are overwritten so a re-scrape always reflects the latest metadata. When the
// folder already holds a sidecar the reader would pick (movie.nfo /
// <dirname>.nfo), that file is updated in place instead of adding a second,
// divergent NFO.
func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
@@ -167,17 +175,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
}
}
doc = movieNFO{
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
UniqueIDs: nfoExternalUniqueIDs(m),
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
}
}
out, err := xml.MarshalIndent(doc, "", " ")
@@ -185,12 +194,49 @@ func WriteMediaNFO(m *model.Media) (string, error) {
return "", err
}
dst := nfoPath(resolveMappedDestinationPath(m.Path))
if m.SeasonNum <= 0 && m.EpisodeNum <= 0 {
dst = nfoExportTarget(dst)
}
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players.
return "", err
}
return dst, nil
}
// nfoExportTarget picks the file an export should write for a movie.
//
// Besides the canonical "<base>.nfo", the reader (findMovieNFO) also accepts
// movie.nfo and "<dirname>.nfo". Writing the canonical name into a folder that
// already carries one of those left two divergent NFOs for the same movie, and
// because the reader prefers the canonical one the pre-existing file quietly went
// stale. Reuse whichever file the reader would already have picked, in the same
// precedence order, so an export updates the existing sidecar in place.
//
// Episodes are deliberately excluded by the caller: for an episode the
// alternatives are series-level files, and writing episode metadata there would
// corrupt the series identity.
func nfoExportTarget(canonical string) string {
dir := filepath.Dir(canonical)
candidates := []string{
filepath.Base(canonical),
"movie.nfo",
filepath.Base(dir) + ".nfo",
}
for _, name := range candidates {
if name == "" || name == "." {
continue
}
candidate := filepath.Join(dir, name)
if candidate == canonical {
continue
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return canonical
}
func splitNFOList(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
@@ -202,3 +248,27 @@ func splitNFOList(value string) []string {
}
return out
}
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
if m == nil {
return nil
}
out := make([]nfoUniqueID, 0, 3)
if m.BangumiID > 0 {
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
}
if id := strings.TrimSpace(m.DoubanID); id != "" {
out = append(out, nfoUniqueID{Type: "douban", Value: id})
}
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
}
if len(out) == 0 {
return nil
}
return out
}
@@ -0,0 +1,145 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func writeNFOTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// A folder that already carries the Kodi/Emby convention "movie.nfo" must be
// updated in place. Exporting used to always write "<filename>.nfo", leaving two
// divergent NFOs for one movie — and since the reader prefers "<filename>.nfo",
// the pre-existing movie.nfo silently went stale.
func TestWriteMediaNFOUpdatesExistingMovieNFO(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "The Shawshank Redemption (1994).mp4")
writeNFOTestFile(t, mediaPath, "video")
existing := filepath.Join(dir, "movie.nfo")
writeNFOTestFile(t, existing, `<?xml version="1.0"?><movie><title>旧标题</title></movie>`)
media := &model.Media{
Title: "肖申克的救赎",
Year: 1994,
Path: mediaPath,
Overview: "希望让人自由。",
TMDbID: 278,
ScrapeStatus: "matched",
}
dst, err := WriteMediaNFO(media)
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(existing) {
t.Fatalf("export wrote %q, want the existing movie.nfo at %q", dst, existing)
}
if _, err := os.Stat(filepath.Join(dir, "The Shawshank Redemption (1994).nfo")); err == nil {
t.Fatal("export created a duplicate <filename>.nfo next to movie.nfo")
}
body, err := os.ReadFile(existing)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "旧标题") || !strings.Contains(string(body), "肖申克的救赎") {
t.Fatalf("movie.nfo was not refreshed: %s", string(body))
}
}
// With no pre-existing sidecar the canonical <filename>.nfo is still the target.
func TestWriteMediaNFOWritesCanonicalNameByDefault(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception (2010).mkv")
writeNFOTestFile(t, mediaPath, "video")
dst, err := WriteMediaNFO(&model.Media{Title: "盗梦空间", Year: 2010, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
want := filepath.Join(dir, "Inception (2010).nfo")
if filepath.Clean(dst) != filepath.Clean(want) {
t.Fatalf("export wrote %q, want %q", dst, want)
}
}
// Re-exporting over an existing canonical sidecar must keep updating that file.
func TestWriteMediaNFOOverwritesCanonicalSidecar(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception (2010).mkv")
writeNFOTestFile(t, mediaPath, "video")
canonical := filepath.Join(dir, "Inception (2010).nfo")
writeNFOTestFile(t, canonical, `<?xml version="1.0"?><movie><title>old</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{Title: "盗梦空间", Year: 2010, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(canonical) {
t.Fatalf("export wrote %q, want %q", dst, canonical)
}
body, _ := os.ReadFile(canonical)
if strings.Contains(string(body), ">old<") {
t.Fatal("existing canonical sidecar was not refreshed")
}
}
// Episode exports must never be redirected to the series-level movie.nfo /
// <dirname>.nfo files: those describe the show, not the episode.
func TestWriteMediaNFOEpisodeIgnoresSeriesLevelSidecars(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Show - S01E01.mkv")
writeNFOTestFile(t, mediaPath, "video")
seriesNFO := filepath.Join(dir, "movie.nfo")
writeNFOTestFile(t, seriesNFO, `<?xml version="1.0"?><movie><title>series level</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{
Title: "Show",
Path: mediaPath,
SeasonNum: 1,
EpisodeNum: 1,
})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) == filepath.Clean(seriesNFO) {
t.Fatal("episode export must not overwrite the series-level movie.nfo")
}
if filepath.Clean(dst) != filepath.Clean(filepath.Join(dir, "Show - S01E01.nfo")) {
t.Fatalf("episode export wrote %q, want the episode sidecar", dst)
}
body, _ := os.ReadFile(seriesNFO)
if !strings.Contains(string(body), "series level") {
t.Fatal("series-level movie.nfo was modified by an episode export")
}
}
// The <dirname>.nfo convention must be reused too.
func TestWriteMediaNFOUpdatesDirectoryNamedNFO(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "Interstellar (2014)")
mediaPath := filepath.Join(dir, "Interstellar (2014).mkv")
writeNFOTestFile(t, mediaPath, "video")
dirNFO := filepath.Join(dir, "Interstellar (2014).nfo")
// The directory-named sidecar is the only pre-existing candidate besides the
// canonical name, so the canonical name is skipped and this one is reused.
writeNFOTestFile(t, dirNFO, `<?xml version="1.0"?><movie><title>old</title></movie>`)
dst, err := WriteMediaNFO(&model.Media{Title: "星际穿越", Year: 2014, Path: mediaPath})
if err != nil {
t.Fatalf("WriteMediaNFO: %v", err)
}
if filepath.Clean(dst) != filepath.Clean(dirNFO) {
t.Fatalf("export wrote %q, want %q", dst, dirNFO)
}
}
+48
View File
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
t.Fatalf("expected originaltitle with code, got:\n%s", text)
}
}
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
t.Fatal(err)
}
dst, err := WriteMediaNFO(&model.Media{
Title: "SIVR-270-河北彩花",
OriginalName: "SIVR-270",
Path: mediaPath,
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
})
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
text := string(body)
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
// 分组键拆成多张卡。
for _, want := range []string{
`<uniqueid type="douban">SIVR-270</uniqueid>`,
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
} {
if !strings.Contains(text, want) {
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
}
}
meta, err := ReadLocalMetadata(mediaPath, "", false)
if err != nil {
t.Fatal(err)
}
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
t.Fatalf("external ids did not round trip: %#v", meta)
}
}
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
@@ -24,7 +25,10 @@ func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T)
}
query := r.URL.Query().Get("query")
queries = append(queries, query)
if query != "motherhood of taihang" {
// Real providers match the query case-insensitively; keep this stub
// faithful so the test guards release-noise cleaning rather than the
// historical lowercased query spelling.
if !strings.EqualFold(query, "motherhood of taihang") {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
@@ -81,7 +85,7 @@ func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T)
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized media missing at %q: %v; items=%#v queries=%v", want, err, res.Items, queries)
}
if len(queries) == 0 || queries[0] != "motherhood of taihang" {
if len(queries) == 0 || !strings.EqualFold(queries[0], "motherhood of taihang") {
t.Fatalf("first metadata query = %q, want cleaned title; all queries=%v", firstQuery(queries), queries)
}
}
@@ -74,7 +74,9 @@ func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) {
func TestOrganizeDirectoryUsesBangumiForAnimeRename(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/subject/frieren" {
// Bangumi takes the keyword in the path; compare case-insensitively so the
// stub does not pin the query spelling (titles now keep their original case).
if !strings.EqualFold(r.URL.Path, "/search/subject/frieren") {
http.NotFound(w, r)
return
}
+289
View File
@@ -0,0 +1,289 @@
package service
import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// PlaybackQuality 是返回给播放器的统一画质选项。
//
// Source:
// - cloud: 115 云端转码清晰度
// - original: 115 原文件(直连,可能仍是 MKV/HEVC/PGS)
// - local: MeBox 本地 HLS 重新编码清晰度
type PlaybackQuality struct {
ID string `json:"id"`
Label string `json:"label"`
Height int `json:"height,omitempty"`
Source string `json:"source"`
Available bool `json:"available"`
RequiresTranscode bool `json:"requires_transcode,omitempty"`
RequiresVIP bool `json:"requires_vip,omitempty"`
Note string `json:"note,omitempty"`
}
type cloud115QualitySpec struct {
ID string
Label string
Height int
VIP bool
}
var cloud115QualityLadder = []cloud115QualitySpec{
{ID: "1", Label: "标清", Height: 480},
{ID: "2", Label: "高清", Height: 540},
{ID: "3", Label: "超清", Height: 720},
{ID: "4", Label: "1080P", Height: 1080},
{ID: "5", Label: "4K", Height: 2160, VIP: true},
}
var resolutionTokenRE = regexp.MustCompile(`(?i)(\d{3,4})p`)
func mediaSourceHeight(m *model.Media) int {
if m == nil {
return 0
}
if m.Height > 0 {
return m.Height
}
for _, text := range []string{m.OriginalName, m.Path, m.STRMURL} {
if h := parseHeightToken(text); h > 0 {
return h
}
}
return 0
}
func parseHeightToken(text string) int {
text = strings.TrimSpace(text)
if text == "" {
return 0
}
upper := strings.ToUpper(text)
if strings.Contains(upper, "4K") || strings.Contains(upper, "2160P") {
return 2160
}
for _, match := range resolutionTokenRE.FindAllStringSubmatch(text, -1) {
if len(match) < 2 {
continue
}
height, err := strconv.Atoi(match[1])
if err != nil || height < 240 || height > 4320 {
continue
}
return height
}
return 0
}
func cloud115AvailableDefinitions(data *cloud115.VideoPlayData) map[string]bool {
available := map[string]bool{}
if data == nil {
return available
}
for key := range data.DefinitionListNew {
key = strings.TrimSpace(key)
if key != "" {
available[key] = true
}
}
for key := range data.DefinitionList {
key = strings.TrimSpace(key)
if key != "" {
available[key] = true
}
}
for _, item := range data.VideoURL {
id := item.DefinitionN
if id <= 0 {
id = item.Definition
}
if id > 0 {
available[strconv.Itoa(id)] = true
}
}
return available
}
func cloud115SourceHeight(m *model.Media, data *cloud115.VideoPlayData) int {
if height := mediaSourceHeight(m); height > 0 {
return height
}
maxHeight := 0
if data != nil {
for _, item := range data.VideoURL {
if item.Height > maxHeight {
maxHeight = item.Height
}
}
}
if maxHeight > 0 {
return maxHeight
}
return 1080
}
// Cloud115QualityOptions 返回 115 已转码和“按源分辨率可转码”的清晰度。
// 未出现在 definition_list_new/video_url 中的档位会标记为需要云端转码。
func Cloud115QualityOptions(m *model.Media, data *cloud115.VideoPlayData) []PlaybackQuality {
sourceHeight := cloud115SourceHeight(m, data)
available := cloud115AvailableDefinitions(data)
out := make([]PlaybackQuality, 0, len(cloud115QualityLadder)+1)
out = append(out, PlaybackQuality{
ID: "100",
Label: "原画",
Height: sourceHeight,
Source: "original",
Available: true,
Note: "播放原始文件,可能仍是 MKV/HEVC/PGS",
})
for _, spec := range cloud115QualityLadder {
if sourceHeight > 0 && spec.Height > sourceHeight && !available[spec.ID] {
continue
}
label := spec.Label
if data != nil && strings.TrimSpace(data.DefinitionListNew[spec.ID]) != "" {
label = strings.TrimSpace(data.DefinitionListNew[spec.ID])
}
isAvailable := available[spec.ID]
item := PlaybackQuality{
ID: spec.ID,
Label: label,
Height: spec.Height,
Source: "cloud",
Available: isAvailable,
RequiresTranscode: !isAvailable,
RequiresVIP: spec.VIP,
}
if !isAvailable {
item.Note = "需要 115 云端转码"
}
if spec.VIP {
if item.Note != "" {
item.Note += ","
}
item.Note += "需要年费 VIP"
}
out = append(out, item)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Source != out[j].Source {
return out[i].Source == "original"
}
if out[i].Height != out[j].Height {
return out[i].Height > out[j].Height
}
return out[i].ID < out[j].ID
})
return out
}
// DefaultCloud115Quality 默认优先 1080P;没有 1080P 时退回最高的云端档位。
func DefaultCloud115Quality(options []PlaybackQuality) string {
for _, option := range options {
if option.ID == "4" && option.Source == "cloud" {
return option.ID
}
}
best := ""
bestHeight := -1
for _, option := range options {
if option.Source != "cloud" {
continue
}
if option.Height > bestHeight {
best = option.ID
bestHeight = option.Height
}
}
if best != "" {
return best
}
return "4"
}
// LocalQualityOptions 返回 MeBox 本地 HLS 可用的画质档位。
//
// transcodeAvailable 为 false(未装 ffmpeg、或转码被全局关闭)时档位仍然返回,
// 但 Available 为 false:播放器据此直接走直连播放,而不是先请求 /api/hls 拿到
// 一个必然失败的 500 再回退。档位列表保留是为了让前端仍能展示"需要转码"的说明。
func LocalQualityOptions(m *model.Media, transcodeAvailable bool) []PlaybackQuality {
sourceHeight := mediaSourceHeight(m)
presets := []PlaybackQuality{
{ID: "source", Label: "原画", Height: sourceHeight, Source: "local", Available: transcodeAvailable, Note: "本地 HLS,保持源分辨率"},
{ID: "1080", Label: "1080P", Height: 1080, Source: "local", Available: transcodeAvailable},
{ID: "720", Label: "720P", Height: 720, Source: "local", Available: transcodeAvailable},
{ID: "480", Label: "480P", Height: 480, Source: "local", Available: transcodeAvailable},
}
if !transcodeAvailable {
for i := range presets {
presets[i].RequiresTranscode = true
if presets[i].Note == "" {
presets[i].Note = "需要 ffmpeg,当前不可用"
}
}
}
out := make([]PlaybackQuality, 0, len(presets))
for _, preset := range presets {
if preset.ID != "source" && sourceHeight > 0 && preset.Height > sourceHeight {
continue
}
out = append(out, preset)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].ID == "source" {
return true
}
if out[j].ID == "source" {
return false
}
return out[i].Height > out[j].Height
})
return out
}
// LocalHLSQuality 是传给 ffmpeg 的本地 HLS 画质参数。
type LocalHLSQuality struct {
ID string
Label string
Height int
VideoBitrate string
MaxRate string
BufSize string
}
var localHLSQualities = map[string]LocalHLSQuality{
"source": {ID: "source", Label: "原画", Height: 0, VideoBitrate: "8000k", MaxRate: "10000k", BufSize: "12000k"},
"1080": {ID: "1080", Label: "1080P", Height: 1080, VideoBitrate: "4000k", MaxRate: "4500k", BufSize: "6000k"},
"720": {ID: "720", Label: "720P", Height: 720, VideoBitrate: "2500k", MaxRate: "2800k", BufSize: "4000k"},
"480": {ID: "480", Label: "480P", Height: 480, VideoBitrate: "1200k", MaxRate: "1400k", BufSize: "2000k"},
}
func LocalHLSQualityByID(id string) (LocalHLSQuality, bool) {
quality, ok := localHLSQualities[strings.TrimSpace(id)]
return quality, ok
}
func DefaultLocalHLSQualityID(m *model.Media) string {
sourceHeight := mediaSourceHeight(m)
switch {
case sourceHeight >= 1080:
return "1080"
case sourceHeight >= 720:
return "720"
case sourceHeight >= 480:
return "480"
case sourceHeight > 0:
return "source"
default:
return "720"
}
}
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115QualityOptionsDefault1080AndTranscode(t *testing.T) {
media := &model.Media{Height: 1080}
data := &cloud115.VideoPlayData{
DefinitionListNew: map[string]string{"3": "超清"},
VideoURL: []cloud115.VideoURLItem{
{Definition: 3, DefinitionN: 3, Height: 720, Width: 1280, Title: "超清"},
},
}
options := Cloud115QualityOptions(media, data)
if got := DefaultCloud115Quality(options); got != "4" {
t.Fatalf("default quality = %q, want 4", got)
}
quality4, ok := findPlaybackQuality(options, "4")
if !ok {
t.Fatal("1080P option missing")
}
if quality4.Available || !quality4.RequiresTranscode {
t.Fatalf("1080P should require transcode: %#v", quality4)
}
quality3, ok := findPlaybackQuality(options, "3")
if !ok || !quality3.Available || quality3.RequiresTranscode {
t.Fatalf("超清 should be available: %#v", quality3)
}
if quality4K, ok := findPlaybackQuality(options, "5"); ok {
t.Fatalf("1080P source should not list 4K: %#v", quality4K)
}
}
func TestCloud115QualityOptionsListsAvailableOnlyOnce(t *testing.T) {
media := &model.Media{Height: 2160}
data := &cloud115.VideoPlayData{
DefinitionListNew: map[string]string{"4": "1080P", "5": "4K"},
VideoURL: []cloud115.VideoURLItem{
{Definition: 4, DefinitionN: 4, Height: 1080},
{Definition: 5, DefinitionN: 5, Height: 2160},
},
}
options := Cloud115QualityOptions(media, data)
for _, id := range []string{"4", "5"} {
quality, ok := findPlaybackQuality(options, id)
if !ok || !quality.Available {
t.Fatalf("quality %s should be available: %#v", id, quality)
}
}
quality5, _ := findPlaybackQuality(options, "5")
if !quality5.RequiresVIP {
t.Fatalf("4K should be marked VIP: %#v", quality5)
}
}
func TestLocalQualityOptionsFollowSourceHeight(t *testing.T) {
media := &model.Media{Height: 720}
options := LocalQualityOptions(media, true)
if _, ok := findPlaybackQuality(options, "1080"); ok {
t.Fatal("720p source should not list 1080P")
}
for _, id := range []string{"source", "720", "480"} {
quality, ok := findPlaybackQuality(options, id)
if !ok {
t.Fatalf("local quality %s missing", id)
}
if !quality.Available {
t.Fatalf("local quality %s should be available while transcoding works", id)
}
}
if got := DefaultLocalHLSQualityID(media); got != "720" {
t.Fatalf("default local quality = %q, want 720", got)
}
}
// Without a usable ffmpeg the local HLS renditions must not advertise themselves
// as available: the player would request /api/hls/... and take a guaranteed 500
// before falling back to direct play.
func TestLocalQualityOptionsMarkUnavailableWithoutTranscoder(t *testing.T) {
media := &model.Media{Height: 1080}
for _, id := range []string{"source", "1080", "720", "480"} {
for _, available := range []bool{true, false} {
quality, ok := findPlaybackQuality(LocalQualityOptions(media, available), id)
if !ok {
t.Fatalf("local quality %s missing", id)
}
if quality.Available != available {
t.Fatalf("quality %s availability = %v, want %v", id, quality.Available, available)
}
if !available && !quality.RequiresTranscode {
t.Fatalf("unavailable quality %s should be flagged as requiring transcoding", id)
}
}
}
}
func TestLocalHLSQualityFFmpegArgs(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = false
cfg.Transcoder.HardwareAccel = false
quality480, _ := LocalHLSQualityByID("480")
args := buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &quality480}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "scale=-2:min(480\\,ih)") {
t.Fatalf("480P scale filter missing: %s", joined)
}
if !strings.Contains(joined, "-b:v 1200k") {
t.Fatalf("480P bitrate missing: %s", joined)
}
cfg.Transcoder.HardwareAccel = true
cfg.Transcoder.Encoder = "nvenc"
qualitySource, _ := LocalHLSQualityByID("source")
args = buildFFmpegArgsForInput(cfg, transcodeInput{Source: "/x.mkv", Quality: &qualitySource}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined = strings.Join(args, " ")
if strings.Contains(joined, "h264_nvenc") {
t.Fatalf("source quality should not use hardware scale path: %s", joined)
}
if strings.Contains(joined, "scale=-2:min(") {
t.Fatalf("source quality should not downscale: %s", joined)
}
if !strings.Contains(joined, "libx264") {
t.Fatalf("source quality should use software x264: %s", joined)
}
}
+79
View File
@@ -144,6 +144,85 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
return normalized, nil
}
// GetLibraryTags returns the user's library tag groups, filtered to libraries
// the user can still access. Empty tags are kept so an editor does not lose a
// tag that was just created.
func (p *ProfileService) GetLibraryTags(ctx context.Context, userID string) ([]model.LibraryTagSet, error) {
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
tags := model.NormalizeLibraryTags(user.DecodeLibraryTags())
if len(tags) == 0 {
return []model.LibraryTagSet{}, nil
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
for i := range tags {
tags[i].LibraryIDs = filterPinnedLibraryIDs(tags[i].LibraryIDs, accessible)
if tags[i].LibraryIDs == nil {
tags[i].LibraryIDs = []string{}
}
}
return tags, nil
}
// SetLibraryTags persists the user's library tag groups after dropping
// inaccessible libraries. A library belongs to at most one tag: the first tag
// that lists it wins, so the saved state always matches the tab UI.
func (p *ProfileService) SetLibraryTags(ctx context.Context, userID string, tags []model.LibraryTagSet) ([]model.LibraryTagSet, error) {
if userID == "" {
return nil, errors.New("missing user id")
}
user, err := p.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("user not found")
}
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
if err != nil {
return nil, err
}
normalized := model.NormalizeLibraryTags(tags)
if len(normalized) > model.MaxLibraryTags {
normalized = normalized[:model.MaxLibraryTags]
}
claimed := make(map[string]struct{})
for i := range normalized {
filtered := make([]string, 0, len(normalized[i].LibraryIDs))
for _, id := range filterPinnedLibraryIDs(normalized[i].LibraryIDs, accessible) {
if _, taken := claimed[id]; taken {
continue
}
claimed[id] = struct{}{}
filtered = append(filtered, id)
}
normalized[i].LibraryIDs = filtered
}
raw, err := model.EncodeLibraryTags(normalized)
if err != nil {
return nil, err
}
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
"library_tags": raw,
}); err != nil {
return nil, err
}
if normalized == nil {
normalized = []model.LibraryTagSet{}
}
return normalized, nil
}
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
libs, err := p.repo.Library.List(ctx)
if err != nil {
@@ -0,0 +1,150 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
)
func TestProfileLibraryTagsFiltersInaccessibleAndKeepsOneTagPerLibrary(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.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
for _, lib := range []*model.Library{libA, libB, libHidden} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
}); err != nil {
t.Fatal(err)
}
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: " 热门 ", LibraryIDs: []string{libA.ID, libHidden.ID, "missing", libA.ID}},
{Name: "热门", LibraryIDs: []string{libB.ID, libA.ID}},
{Name: "", LibraryIDs: []string{libB.ID}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 1 {
t.Fatalf("SetLibraryTags returned %d tags, want 1 (%#v)", len(got), got)
}
if got[0].Name != "热门" {
t.Fatalf("tag name = %q, want 热门", got[0].Name)
}
// libA 已在第一个标签里占位,第二个标签里的 libA 应被丢弃。
want := []string{libA.ID, libB.ID}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 1 || loaded[0].Name != "热门" || len(loaded[0].LibraryIDs) != len(want) {
t.Fatalf("GetLibraryTags = %#v, want one tag with %v", loaded, want)
}
}
func TestProfileLibraryTagsKeepsEmptyTagAndMountedEmby(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.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{
AccountID: "acct-1",
RemoteViewID: "view-42",
RemoteViewName: "Remote Movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatal(err)
}
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
got, err := svc.SetLibraryTags(t.Context(), user.ID, []model.LibraryTagSet{
{Name: "远程", LibraryIDs: []string{remoteID, "embyremote~missing~view"}},
{Name: "以后再用", LibraryIDs: []string{}},
})
if err != nil {
t.Fatalf("SetLibraryTags: %v", err)
}
if len(got) != 2 {
t.Fatalf("SetLibraryTags = %#v, want 2 tags", got)
}
if len(got[0].LibraryIDs) != 1 || got[0].LibraryIDs[0] != remoteID {
t.Fatalf("remote tag ids = %v, want [%s]", got[0].LibraryIDs, remoteID)
}
if got[1].Name != "以后再用" || len(got[1].LibraryIDs) != 0 {
t.Fatalf("empty tag should survive as-is, got %#v", got[1])
}
loaded, err := svc.GetLibraryTags(t.Context(), user.ID)
if err != nil {
t.Fatalf("GetLibraryTags: %v", err)
}
if len(loaded) != 2 || len(loaded[1].LibraryIDs) != 0 {
t.Fatalf("GetLibraryTags = %#v, want the empty tag preserved", loaded)
}
}
func TestProfileLibraryTagsEmptyStateReturnsEmptySlice(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.Library{}, &model.EmbyMount{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
if tags, err := svc.GetLibraryTags(t.Context(), user.ID); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("GetLibraryTags = %#v, %v; want empty non-nil slice", tags, err)
}
if tags, err := svc.SetLibraryTags(t.Context(), user.ID, nil); err != nil || tags == nil || len(tags) != 0 {
t.Fatalf("SetLibraryTags(nil) = %#v, %v; want empty non-nil slice", tags, err)
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ One\.Piece\.S01E(89[2-9]|9\d{2}|10\d{2})\.1999 => 海贼王.S21E\1.1999 && S21E
func TestCleanQueryWithRecognitionDisabledByDefaultRepoNil(t *testing.T) {
title, year := CleanQueryWithRecognition(t.Context(), nil, "Dune.2021.2160p.WEB-DL.mkv")
if title != "dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want dune/2021", title, year)
if title != "Dune" || year != 2021 {
t.Fatalf("CleanQueryWithRecognition = %q/%d, want Dune/2021", title, year)
}
}
+132 -21
View File
@@ -25,7 +25,10 @@ const (
type RuntimeCacheService struct {
log *zap.Logger
client *redis.Client
prefix string
// redisGet is separated from client so cache ordering can be tested without
// requiring a live Redis instance.
redisGet func(ctx context.Context, key string) ([]byte, error)
prefix string
mu sync.RWMutex
memory map[string]runtimeCacheItem
@@ -33,13 +36,25 @@ type RuntimeCacheService struct {
limit int
maxBytes int64
bytesUsed int64
// seq stamps every insert/access with a strictly increasing number. Two
// entries written or touched inside the same clock tick carry an identical
// lastUsed, and Go map iteration order is random, so eviction used to pick an
// arbitrary "oldest" entry. seq turns recency into a total order.
seq uint64
}
func (c *RuntimeCacheService) nextSeqLocked() uint64 {
c.seq++
return c.seq
}
type runtimeCacheItem struct {
raw []byte
expiresAt time.Time
lastUsed time.Time
size int64
raw []byte
expiresAt time.Time
staleUntil time.Time
lastUsed time.Time
seq uint64
size int64
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
@@ -51,6 +66,7 @@ type runtimeObjectItem struct {
value any
expiresAt time.Time
lastUsed time.Time
seq uint64
size int64
}
@@ -95,6 +111,9 @@ func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheSe
return c
}
c.client = client
c.redisGet = func(ctx context.Context, key string) ([]byte, error) {
return client.Get(ctx, key).Bytes()
}
if log != nil {
log.Info("redis runtime cache enabled with in-process L1", zap.String("addr", opts.Addr), zap.String("prefix", c.prefix))
}
@@ -120,8 +139,8 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
if raw, ok := c.getMemory(fullKey); ok {
return json.Unmarshal(raw, out) == nil
}
if c.client != nil {
raw, err := c.client.Get(ctx, fullKey).Bytes()
if c.redisGet != nil {
raw, err := c.redisGet(ctx, fullKey)
if err == nil {
if json.Unmarshal(raw, out) != nil {
return false
@@ -133,6 +152,36 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
return false
}
// GetJSONStale returns a fresh value when available and otherwise a retained
// stale value stored with SetJSONWithStale. The stale flag lets
// callers serve immediately while refreshing in the background.
func (c *RuntimeCacheService) GetJSONStale(ctx context.Context, key string, out any) (found bool, stale bool) {
if !c.Enabled() || strings.TrimSpace(key) == "" || out == nil {
return false, false
}
fullKey := c.key(key)
raw, ok, isStale := c.getMemoryWithStale(fullKey)
if ok && !isStale {
return json.Unmarshal(raw, out) == nil, false
}
// A stale L1 entry must not hide a newer value written by another
// instance. Prefer Redis whenever the local copy is stale, then fall back
// to it only when Redis is unavailable or its fresh value has expired.
if c.redisGet != nil {
redisRaw, err := c.redisGet(ctx, fullKey)
if err == nil {
if json.Unmarshal(redisRaw, out) == nil {
c.setMemoryOwned(fullKey, redisRaw, 2*time.Second)
return true, false
}
}
}
if ok {
return json.Unmarshal(raw, out) == nil, isStale
}
return false, false
}
func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
@@ -148,6 +197,27 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
}
}
// SetJSONWithStale stores a fresh value for freshTTL and keeps an in-process
// stale copy for staleTTL. Redis keeps only the fresh window so multi-instance
// deployments retain the existing consistency semantics.
func (c *RuntimeCacheService) SetJSONWithStale(ctx context.Context, key string, value any, freshTTL, staleTTL time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || freshTTL <= 0 {
return
}
if staleTTL < freshTTL {
staleTTL = freshTTL
}
raw, err := json.Marshal(value)
if err != nil {
return
}
fullKey := c.key(key)
c.setMemoryBytesWithStale(fullKey, raw, freshTTL, staleTTL, true)
if c.client != nil {
_ = c.client.Set(ctx, fullKey, raw, freshTTL).Err()
}
}
// GetObject 返回缓存中的对象。返回值不可变:调用方需要修改时必须先自行拷贝。
func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
if !c.Enabled() || strings.TrimSpace(key) == "" {
@@ -167,6 +237,7 @@ func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
return nil, false
}
item.lastUsed = now
item.seq = c.nextSeqLocked()
c.obj[fullKey] = item
c.mu.Unlock()
return item.value, true
@@ -194,6 +265,7 @@ func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration
value: value,
expiresAt: now.Add(ttl),
lastUsed: now,
seq: c.nextSeqLocked(),
size: size,
}
c.bytesUsed += size
@@ -255,20 +327,30 @@ func (c *RuntimeCacheService) key(key string) string {
}
func (c *RuntimeCacheService) getMemory(key string) ([]byte, bool) {
raw, ok, stale := c.getMemoryWithStale(key)
return raw, ok && !stale
}
func (c *RuntimeCacheService) getMemoryWithStale(key string) ([]byte, bool, bool) {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.memory[key]
if !ok {
return nil, false
return nil, false, false
}
if !now.Before(item.expiresAt) {
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
return nil, false
return nil, false, false
}
item.lastUsed = now
item.seq = c.nextSeqLocked()
c.memory[key] = item
return item.raw, true
return item.raw, true, !now.Before(item.expiresAt)
}
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
@@ -280,9 +362,16 @@ func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Du
}
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
if ttl <= 0 || len(raw) == 0 {
c.setMemoryBytesWithStale(key, raw, ttl, ttl, owned)
}
func (c *RuntimeCacheService) setMemoryBytesWithStale(key string, raw []byte, freshTTL, staleTTL time.Duration, owned bool) {
if freshTTL <= 0 || len(raw) == 0 {
return
}
if staleTTL < freshTTL {
staleTTL = freshTTL
}
size := int64(len(key)+len(raw)) + runtimeCacheEntryOverheadBytes
now := time.Now()
c.mu.Lock()
@@ -298,10 +387,12 @@ func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Du
raw = append([]byte(nil), raw...)
}
c.memory[key] = runtimeCacheItem{
raw: raw,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
raw: raw,
expiresAt: now.Add(freshTTL),
staleUntil: now.Add(staleTTL),
lastUsed: now,
seq: c.nextSeqLocked(),
size: size,
}
c.bytesUsed += size
}
@@ -334,7 +425,11 @@ func (c *RuntimeCacheService) entryCountLocked() int {
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
for key, item := range c.memory {
if !now.Before(item.expiresAt) {
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
}
}
@@ -345,20 +440,36 @@ func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
}
}
// evictOldestLocked removes the least recently used entry across both caches.
//
// Recency is ordered by (lastUsed, seq): entries written within the same clock
// tick share lastUsed, and map iteration order is randomized, so comparing
// timestamps alone let eviction drop a just-written entry instead of the older
// one. seq breaks those ties deterministically.
func (c *RuntimeCacheService) evictOldestLocked() bool {
var (
oldestKey string
oldestKind byte
oldestAt time.Time
oldestSeq uint64
)
older := func(at time.Time, seq uint64) bool {
if oldestKind == 0 {
return true
}
if at.Equal(oldestAt) {
return seq < oldestSeq
}
return at.Before(oldestAt)
}
for key, item := range c.memory {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'm', item.lastUsed
if older(item.lastUsed, item.seq) {
oldestKey, oldestKind, oldestAt, oldestSeq = key, 'm', item.lastUsed, item.seq
}
}
for key, item := range c.obj {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'o', item.lastUsed
if older(item.lastUsed, item.seq) {
oldestKey, oldestKind, oldestAt, oldestSeq = key, 'o', item.lastUsed, item.seq
}
}
switch oldestKind {
@@ -0,0 +1,108 @@
package service
import (
"context"
"strings"
"testing"
"time"
)
// Eviction must be deterministic when entries share a lastUsed timestamp.
//
// Regression: recency was compared by lastUsed alone. Entries written within the
// same clock tick carry identical timestamps and Go map iteration order is
// randomized, so eviction could drop a just-written entry instead of the oldest
// one — which made TestRuntimeCacheSetMaxSizeEvictsImmediately fail intermittently
// in a full-package run.
func TestRuntimeCacheEvictionBreaksTimestampTiesDeterministically(t *testing.T) {
// Repeat because the bug only showed up for some map iteration orders.
for attempt := 0; attempt < 50; attempt++ {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
// Two entries large enough that a 1MiB budget can hold only one. Writing
// them back to back puts them in the same clock tick often enough to hit
// the tie almost immediately.
cache.SetObject("old", strings.Repeat("a", perEntry), time.Minute)
cache.SetObject("new", strings.Repeat("b", perEntry), time.Minute)
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("old"); ok {
t.Fatalf("attempt %d: oldest entry survived the budget drop", attempt)
}
if _, ok := cache.GetObject("new"); !ok {
t.Fatalf("attempt %d: newest entry was evicted instead of the oldest", attempt)
}
}
}
// Accessing an entry refreshes its recency even when it lands in the same tick as
// the write of a competing entry.
func TestRuntimeCacheAccessRefreshesEvictionOrder(t *testing.T) {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
cache.SetObject("first", strings.Repeat("a", perEntry), time.Minute)
cache.SetObject("second", strings.Repeat("b", perEntry), time.Minute)
// Touch the older entry so it becomes the more recent one.
if _, ok := cache.GetObject("first"); !ok {
t.Fatal("first entry should be present before the budget drop")
}
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("second"); ok {
t.Fatal("the untouched entry should have been evicted first")
}
if _, ok := cache.GetObject("first"); !ok {
t.Fatal("the just-accessed entry should have been kept")
}
}
// Eviction picks the oldest across the byte cache and the object cache together.
func TestRuntimeCacheEvictionComparesBothCaches(t *testing.T) {
cache := newRuntimeCacheForTest(t, 128)
const perEntry = 700 << 10
// Byte-cache entry written first, object-cache entry second.
payload := strings.Repeat("c", perEntry)
cache.SetJSON(context.Background(), "bytes", payload, time.Minute)
cache.SetObject("object", strings.Repeat("d", perEntry), time.Minute)
cache.SetMaxSizeMB(1)
if _, ok := cache.GetObject("object"); !ok {
t.Fatal("the newer object entry must be kept")
}
var decoded string
if cache.GetJSON(context.Background(), "bytes", &decoded) {
t.Fatal("the older byte-cache entry should have been evicted")
}
}
// The sequence counter must hand out strictly increasing values, otherwise ties
// would resolve randomly again.
func TestRuntimeCacheSequenceIsMonotonic(t *testing.T) {
cache := newRuntimeCacheForTest(t, 16)
seen := map[uint64]bool{}
cache.mu.Lock()
var previous uint64
for i := 0; i < 100; i++ {
seq := cache.nextSeqLocked()
if seen[seq] {
cache.mu.Unlock()
t.Fatalf("sequence %d handed out twice", seq)
}
if seq <= previous && previous != 0 {
cache.mu.Unlock()
t.Fatalf("sequence went backwards: %d after %d", seq, previous)
}
seen[seq] = true
previous = seq
}
cache.mu.Unlock()
if len(seen) != 100 {
t.Fatalf("expected 100 distinct sequence numbers, got %d", len(seen))
}
}
+71
View File
@@ -116,3 +116,74 @@ func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
t.Fatal("newest entry should remain after lowering cache limit")
}
}
func TestRuntimeCacheReturnsStaleValueAfterFreshTTL(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:stale"
cache.SetJSONWithStale(context.Background(), key, "cached-value", time.Minute, time.Hour)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
item.expiresAt = time.Now().Add(-time.Second)
cache.memory[fullKey] = item
cache.mu.Unlock()
var fresh string
if cache.GetJSON(context.Background(), key, &fresh) {
t.Fatal("expired fresh entry must not be returned by GetJSON")
}
var stale string
found, isStale := cache.GetJSONStale(context.Background(), key, &stale)
if !found || !isStale {
t.Fatalf("GetJSONStale found=%t stale=%t, want true/true", found, isStale)
}
if stale != "cached-value" {
t.Fatalf("stale value=%q, want cached-value", stale)
}
}
func TestRuntimeCacheDoesNotReturnOrdinaryEntryAsStale(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:ordinary"
cache.SetJSON(context.Background(), key, "cached-value", time.Minute)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
expiredAt := time.Now().Add(-time.Second)
item.expiresAt = expiredAt
item.staleUntil = expiredAt
cache.memory[fullKey] = item
cache.mu.Unlock()
var out string
if found, isStale := cache.GetJSONStale(context.Background(), key, &out); found || isStale {
t.Fatalf("ordinary expired entry found=%t stale=%t, want false/false", found, isStale)
}
}
func TestRuntimeCacheStalePrefersFreshRedisValue(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:redis-fresh"
cache.SetJSONWithStale(context.Background(), key, "local-stale", time.Minute, time.Hour)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
item.expiresAt = time.Now().Add(-time.Second)
cache.memory[fullKey] = item
cache.mu.Unlock()
cache.redisGet = func(context.Context, string) ([]byte, error) {
return []byte(`"redis-fresh"`), nil
}
var out string
found, stale := cache.GetJSONStale(context.Background(), key, &out)
if !found || stale {
t.Fatalf("GetJSONStale found=%t stale=%t, want true/false", found, stale)
}
if out != "redis-fresh" {
t.Fatalf("value=%q, want redis-fresh", out)
}
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
// Dot-prefixed directory names are legal and common (hidden media folders,
// ".staging" drop dirs). cleanEntryName used to trim dots from BOTH ends, which
// silently rewrote `.media` into `media`.
func TestCleanEntryNameKeepsLeadingDots(t *testing.T) {
cases := []struct {
name string
isDir bool
want string
}{
{name: ".media", isDir: true, want: ".media"},
{name: ".tmp-live-media", isDir: true, want: ".tmp-live-media"},
{name: ".staging", isDir: true, want: ".staging"},
{name: "Season 01.", isDir: true, want: "Season 01"},
{name: " trailing space ", isDir: true, want: "trailing space"},
{name: ".hidden.mkv", isDir: false, want: ".hidden.mkv"},
{name: "poster.jpg", isDir: false, want: "poster.jpg"},
{name: "file:name?test*<foo>|bar\".mkv", isDir: false, want: "file nametestfoobar.mkv"},
// Names that are nothing but dots must still collapse to a safe placeholder.
{name: ".", isDir: true, want: "unnamed"},
{name: "..", isDir: true, want: "unnamed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := cleanEntryName(tc.name, tc.isDir); got != tc.want {
t.Fatalf("cleanEntryName(%q, %v) = %q, want %q", tc.name, tc.isDir, got, tc.want)
}
})
}
}
// sanitizeLocalPath must preserve a dot-prefixed directory in the middle of an
// absolute path. Regression: artwork sidecars for a library at
// `<root>/.tmp-live-media/电影/...` were written to `<root>/tmp-live-media/...`
// instead — a sibling tree outside the media folder.
func TestSanitizeLocalPathKeepsDotPrefixedSegments(t *testing.T) {
dir := filepath.Join(string(filepath.Separator)+"media", ".hidden-lib", "电影", "Inception (2010)")
got := sanitizeLocalPath(dir)
if got != dir {
t.Fatalf("sanitizeLocalPath(%q) = %q, want the path unchanged", dir, got)
}
}
// End-to-end guard for the observed bug: writing scraped artwork into a media
// folder located under a dot-prefixed library root must land inside that folder,
// never in a dot-stripped sibling directory.
func TestArtworkWriteKeepsDotPrefixedLibraryPath(t *testing.T) {
root := t.TempDir()
libraryRoot := filepath.Join(root, ".media")
mediaDir := filepath.Join(libraryRoot, "电影", "Inception (2010)")
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatal(err)
}
svc := &ScraperService{log: zap.NewNop()}
dst := svc.writeArtworkDataToPath(mediaDir, "Inception (2010)-poster", "image/jpeg", []byte{0xff, 0xd8, 0xff, 0xdb, 0x00})
if dst == "" {
t.Fatal("artwork write reported no destination")
}
if filepath.Dir(filepath.Clean(dst)) != filepath.Clean(mediaDir) {
t.Fatalf("artwork written to %q, want a file inside %q", dst, mediaDir)
}
if _, err := os.Stat(filepath.Join(mediaDir, "Inception (2010)-poster.jpg")); err != nil {
t.Fatalf("sidecar missing from the media folder: %v", err)
}
// A dot-stripped sibling must not be created.
if _, err := os.Stat(filepath.Join(root, "media")); err == nil {
t.Fatal("dot-stripped sibling directory was created")
}
}
+3 -3
View File
@@ -114,11 +114,11 @@ func TestScanLibraryUsesISOParentFolderForScrapeIdentity(t *testing.T) {
if err := repos.DB.First(&media).Error; err != nil {
t.Fatal(err)
}
if media.Title != "dune part two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
if media.Title != "Dune Part Two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
t.Fatalf("ISO scrape identity = title=%q year=%d status=%q", media.Title, media.Year, media.ScrapeStatus)
}
candidates := scrapeQueryCandidates(&media, &lib)
if len(candidates) == 0 || candidates[0] != "dune part two" {
if len(candidates) == 0 || candidates[0] != "Dune Part Two" {
t.Fatalf("ISO scrape candidates = %#v", candidates)
}
}
@@ -161,7 +161,7 @@ func TestScanLibraryRepairsPreviouslyUnmatchedGenericISO(t *testing.T) {
if err := repos.DB.First(&media, "id = ?", legacy.ID).Error; err != nil {
t.Fatal(err)
}
if media.Title != "dune part two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
if media.Title != "Dune Part Two" || media.Year != 2024 || media.ScrapeStatus != "pending" {
t.Fatalf("repaired ISO = title=%q year=%d status=%q", media.Title, media.Year, media.ScrapeStatus)
}
}

Some files were not shown because too many files have changed in this diff Show More