Compare commits

..

14 Commits

Author SHA1 Message Date
truewhile 95d8126d65 优化windows包,优化windos可执行文件功能 2026-09-14 15:48:59 +08:00
truewhile 3aa8662c45 优化,日志脱敏 2026-09-14 15:12:42 +08:00
truewhile 5c85478883 1 2026-09-14 12:33:59 +08:00
truewhile fd54d42db7 优化 2026-09-14 00:02:46 +08:00
truewhile 4595b7c5be 优化 2026-09-13 23:45:21 +08:00
truewhile ee3927cdf1 优化 2026-09-13 23:39:07 +08:00
truewhile 4b34fc4616 优化 2026-09-13 23:11:28 +08:00
truewhile b7f1cd85f8 支持按用户保存播放器与弹幕偏好
在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。
返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。
结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
2026-09-13 23:01:43 +08:00
truewhile be9d3a6d9e 优化 2026-09-13 22:00:50 +08:00
truewhile b8e48a1f45 优化 2026-09-13 21:23:06 +08:00
truewhile 670dd90dbe 优化 2026-09-13 21:03:06 +08:00
truewhile 9a68ef701f 重构核心模块并同步更新项目配置与相关实现 2026-09-13 20:36:14 +08:00
truewhile 386fc358d1 Add configurable memory limits and LRU eviction for runtime cache 2026-09-13 00:41:04 +08:00
truewhile 2989649445 Add Emby-compatible request logging and ordered playback progress 2026-09-12 23:06:44 +08:00
147 changed files with 7570 additions and 2396 deletions
+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)
}
+38
View File
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
}
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
if err != nil {
return nil, nil, err
}
log := zap.New(
zapcore.NewCore(encoder, writer, zap.InfoLevel),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
return log, func() { _ = writer.Close() }, nil
}
func embyCompatLogPath(cfg *config.Config) string {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".emby-compat" + ext
}
return filepath.Join(out, "emby-compat.log")
}
+31
View File
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
}
_ = writer.Sync()
}
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newEmbyCompatLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("emby request", zap.String("client", "Infuse"))
_ = log.Sync()
closeFn()
path := filepath.Join(dir, "logs", "emby-compat.log")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
t.Fatalf("dedicated Emby log missing request data: %s", data)
}
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
}
}
+1 -104
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,95 +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() }()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
)
// 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, 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
+4 -1
View File
@@ -19,13 +19,16 @@ import (
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
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>
+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"
+8 -8
View File
@@ -76,8 +76,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 +113,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 +136,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 +161,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"
+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 -2
View File
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
+2
View File
@@ -7,6 +7,7 @@ const (
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
DefaultCacheMemoryMaxSizeMB = 128
)
func setDefaults(v *viper.Viper) {
@@ -44,6 +45,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
+3
View File
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mebox"
}
+5 -3
View File
@@ -113,10 +113,12 @@ type LoggingConfig struct {
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig 控制磁盘转码/刮削缓存。
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+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)
}
+5
View File
@@ -99,6 +99,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
+3
View File
@@ -68,6 +68,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
}
c.Status(http.StatusNoContent)
}
}
+12 -16
View File
@@ -2,6 +2,7 @@
package handler
import (
"errors"
"net/http"
"strings"
@@ -16,10 +17,8 @@ import (
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := currentUserID(c)
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
opts := service.DanmakuFetchOptions{
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
}
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
opts := service.DanmakuFetchOptions{UserID: uid}
res, err := svc.Danmaku.FetchWithOptions(
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
if err != nil {
@@ -30,17 +29,15 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
}
}
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
// size, area, enabled) so the player can initialize its control panel without
// admin privileges.
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
// preferences so the player can initialize without admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
}
}
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := currentUserID(c)
@@ -48,21 +45,20 @@ func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
var req struct {
MergeSources *bool `json:"merge_sources"`
}
var req service.DanmakuSettingsPatch
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if req.MergeSources == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
c.JSON(http.StatusOK, cfg)
}
}
+55
View File
@@ -133,3 +133,58 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatal("config should reflect the persisted merge preference")
}
}
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\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) {
t.Fatal("response must never expose the application secret")
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
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 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
t.Fatalf("unexpected service config: %+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.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"source":"ftp://dm.example.com"}`, "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.DanmakuSource != "" {
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
}
}
+4 -1
View File
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
nil,
)
wProgress := httptest.NewRecorder()
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
t.Fatalf("unexpected playback session metadata: %#v", hist)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
+32
View File
@@ -0,0 +1,32 @@
package handler
import "testing"
func TestIsEmbyPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{path: "/emby", want: true},
{path: "/emby/System/Info/Public", want: true},
{path: "//emby//Items//1", want: true},
{path: "/System/Info/Public", want: true},
{path: "/Search/Hints", want: true},
{path: "/Playback/BitrateTest", want: true},
{path: "/api/unknown", want: false},
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
{path: "/random/path", want: false},
}
for _, tt := range tests {
if got := IsEmbyPath(tt.path); got != tt.want {
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
if !changed || got != "/emby/api/stream/123" {
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
}
}
+87 -1
View File
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
"embywebsocket": {},
}
var embyRootSegments = map[string]struct{}{
"albums": {},
"artists": {},
"audio": {},
"audiocodecs": {},
"auth": {},
"branding": {},
"channels": {},
"collections": {},
"connect": {},
"containers": {},
"devices": {},
"displaypreferences": {},
"dlna": {},
"encoding": {},
"environment": {},
"gamegenres": {},
"games": {},
"genres": {},
"images": {},
"items": {},
"libraries": {},
"library": {},
"livestreams": {},
"livetv": {},
"localization": {},
"movies": {},
"musicgenres": {},
"news": {},
"notification": {},
"notifications": {},
"officialratings": {},
"packages": {},
"persons": {},
"playback": {},
"playlists": {},
"plugins": {},
"providers": {},
"reports": {},
"scheduledtasks": {},
"search": {},
"sessions": {},
"shows": {},
"songs": {},
"studios": {},
"subtitlecodecs": {},
"sync": {},
"system": {},
"tags": {},
"trailers": {},
"user_usage_stats": {},
"users": {},
"videocodecs": {},
"videos": {},
"years": {},
}
func isEmbyRootSegment(segment string) bool {
if _, ok := embyRootSegments[segment]; ok {
return true
}
_, ok := embyStaticSegments[segment]
return ok
}
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
// 例如 /System/Info/Public、/Users/Public、/Sessions。
func IsEmbyPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
path = multipleSlashesRE.ReplaceAllString(path, "/")
lower := strings.ToLower(path)
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return false
}
return isEmbyRootSegment(strings.ToLower(segments[0]))
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
+8 -1
View File
@@ -19,6 +19,8 @@ type embyPlayingReq struct {
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
PlaySessionID string `json:"PlaySessionId"`
PlaySession string `json:"playSessionId"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
playSessionID := embyFirstNonEmptyString(
req.PlaySessionID,
req.PlaySession,
firstQueryValue(c, "PlaySessionId", "playSessionId"),
)
if itemID == "" {
c.Status(http.StatusOK)
return
@@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
if err := svc.Emby.RecordProgressWithSession(c.Request.Context(), uid, itemID, pos, runTime, playSessionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+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))
+121 -90
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -32,10 +33,10 @@ type createLibraryReq struct {
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
@@ -60,37 +61,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
libs = filtered
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
@@ -103,49 +104,72 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
var previews []service.LibraryPreviewItem
if c.Query("include_total") == "0" {
previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
} else {
previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
// 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短
// 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。
includeRemote := true
if len(targetSet) > 0 {
includeRemote = false
for id := range targetSet {
if service.IsEmbyRemoteID(id) {
includeRemote = true
break
}
}
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if includeRemote && svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
}
allowedViews = append(allowedViews, v)
}
allowedViews = append(allowedViews, v)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
@@ -170,19 +194,19 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if acct == nil {
return
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
}()
@@ -421,9 +445,14 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -439,17 +468,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
}
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
items := service.PaginateMediaItems(grouped, page, size)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"total": len(grouped),
"page": page,
"page_size": size,
})
@@ -699,25 +730,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
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()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
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()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func parseMediaSort(c *gin.Context) service.MediaSortSpec {
if c == nil {
return service.NormalizeMediaSort("", "")
}
return service.NormalizeMediaSort(c.Query("sort"), c.Query("order"))
}
// mediaHistoryMap loads the current user's latest playback time per media ID.
// It is only used for the explicit last_played sort so normal pagination keeps
// its single-query path.
func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time {
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil {
return nil
}
uid, _ := c.Get(middleware.CtxUserID)
userID := strings.TrimSpace(toString(uid))
if userID == "" {
return nil
}
var rows []struct {
MediaID string `gorm:"column:media_id"`
WatchedAt time.Time `gorm:"column:watched_at"`
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.PlaybackHistory{}).
Select("media_id, MAX(watched_at) AS watched_at").
Where("user_id = ?", userID).
Group("media_id").
Scan(&rows).Error; err != nil {
return nil
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
out[row.MediaID] = row.WatchedAt
}
return out
}
+27 -6
View File
@@ -15,9 +15,12 @@ import (
// ─── History ────────────────────────────────────────────────────────────────
type progressReq struct {
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
SessionID string `json:"session_id"`
SessionStartedAtMs int64 `json:"session_started_at_ms"`
Sequence int64 `json:"sequence"`
}
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Playback.RecordProgress(
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
); err != nil {
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
UserID: toString(uid),
MediaID: req.MediaID,
PositionMs: req.PositionMs,
DurationMs: req.DurationMs,
SessionID: req.SessionID,
SessionStartedAtMs: req.SessionStartedAtMs,
Sequence: req.Sequence,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -76,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
if c.Query("ids") == "1" {
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if ids == nil {
ids = []string{}
}
c.JSON(http.StatusOK, gin.H{"ids": ids})
return
}
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+65
View File
@@ -0,0 +1,65 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"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 TestRecordProgressHandlerIgnoresStaleSessionSequence(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{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
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.POST("/history", recordProgressHandler(svc))
post := func(body string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code
}
if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent {
t.Fatalf("newer progress status = %d", code)
}
if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent {
t.Fatalf("stale progress status = %d", code)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || got.Sequence != 2 {
t.Fatalf("stale handler request overwrote newer state: %#v", got)
}
}
+8
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
ctx := c.Request.Context()
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
// 远程剧集库:远程 Series 映射为系列卡片。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
@@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
@@ -122,6 +129,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
+284
View File
@@ -0,0 +1,284 @@
package middleware
import (
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// EmbyCompatLogger 将 Emby/Jellyfin 兼容面的请求独立记录,便于针对具体
// 客户端、设备和未实现接口排查兼容问题。
//
// isEmbyPath 由 handler 层提供,用于识别 /emby/* 以及无前缀根路径形式的
// Emby API。除路径特征外,带 X-Emby-* / X-MediaBrowser-* 等客户端凭据的
// 请求也会被识别,因此尚未实现且没有已知路径前缀的新接口也能被记录。
func EmbyCompatLogger(log *zap.Logger, isEmbyPath func(string) bool) gin.HandlerFunc {
if log == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
if c == nil || c.Request == nil {
return
}
requestPath := c.Request.URL.Path
pathMatched := isEmbyPath != nil && isEmbyPath(requestPath)
hasSignature := hasEmbyClientSignature(c)
if !pathMatched && !hasSignature {
c.Next()
return
}
start := time.Now()
c.Next()
status := c.Writer.Status()
route := c.FullPath()
// 根路径 Emby 命名空间很宽,可能与 SPA 路由重名。没有客户端标识且
// 最终由 SPA 返回 200 时不应污染兼容日志;服务端路由或 404 仍记录。
if !hasSignature && !hasExplicitEmbyPrefix(requestPath) && strings.TrimSpace(route) == "" && status < http.StatusBadRequest {
return
}
unimplemented := isUnimplementedEmbyRequest(c, route, status, hasSignature)
fields := embyCompatLogFields(c, requestPath, status, route, time.Since(start), unimplemented)
switch {
case unimplemented:
log.Warn("emby API not implemented", fields...)
case status >= http.StatusBadRequest:
log.Warn("emby request failed", fields...)
default:
log.Info("emby request", fields...)
}
}
}
func isUnimplementedEmbyRequest(c *gin.Context, route string, status int, hasSignature bool) bool {
if strings.TrimSpace(route) != "" || c.Request.Method == http.MethodOptions {
return false
}
if status == http.StatusNotFound {
return true
}
// 无前端 API 前缀的未知 Emby 路径可能被 SPA 兜底为 200 HTML。
// 已识别为 Emby 客户端的这类响应同样说明兼容接口尚未实现。
return hasSignature && status >= http.StatusOK && status < http.StatusMultipleChoices &&
strings.Contains(strings.ToLower(c.Writer.Header().Get("Content-Type")), "text/html")
}
func embyCompatLogFields(c *gin.Context, requestPath string, status int, route string, duration time.Duration, unimplemented bool) []zap.Field {
client := embyCompatClientInfo(c)
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", requestPath),
zap.String("normalized_path", c.Request.URL.Path),
zap.String("route", route),
zap.Int("status", status),
zap.Duration("duration", duration),
zap.String("ip", c.ClientIP()),
zap.Bool("unimplemented", unimplemented),
}
if queryKeys := sortedQueryKeys(c); len(queryKeys) > 0 {
fields = append(fields, zap.Strings("query_keys", queryKeys))
}
if client.Client != "" {
fields = append(fields, zap.String("client", client.Client))
}
if client.Device != "" {
fields = append(fields, zap.String("device", client.Device))
}
if client.DeviceID != "" {
fields = append(fields, zap.String("device_id", client.DeviceID))
}
if client.Version != "" {
fields = append(fields, zap.String("client_version", client.Version))
}
if client.UserID != "" {
fields = append(fields, zap.String("user_id", client.UserID))
}
if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" {
fields = append(fields, zap.String("user_agent", userAgent))
}
if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" {
fields = append(fields, zap.String("content_type", contentType))
}
if errs := c.Errors.Errors(); len(errs) > 0 {
fields = append(fields, zap.Strings("errors", truncateStrings(errs, 8, 512)))
}
return fields
}
func hasEmbyClientSignature(c *gin.Context) bool {
for _, name := range []string{
"X-Emby-Token",
"X-MediaBrowser-Token",
"X-Emby-Authorization",
"X-MediaBrowser-Authorization",
"X-Emby-Client",
"X-MediaBrowser-Client",
"X-Emby-Device-Id",
"X-Emby-DeviceId",
"X-Emby-Device-Name",
"X-Emby-Version",
"X-Emby-UserId",
"X-MediaBrowser-Device-Id",
"X-MediaBrowser-DeviceId",
"X-MediaBrowser-Device-Name",
"X-MediaBrowser-Version",
"X-MediaBrowser-UserId",
} {
if strings.TrimSpace(c.GetHeader(name)) != "" {
return true
}
}
auth := strings.ToLower(strings.TrimSpace(c.GetHeader("Authorization")))
if strings.HasPrefix(auth, "emby ") || strings.HasPrefix(auth, "mediabrowser ") {
return true
}
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if strings.TrimSpace(c.Query(key)) != "" {
return true
}
}
ua := strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent")))
for _, marker := range []string{
"emby", "jellyfin", "infuse", "senplayer", "fileball", "vidhub", "hills", "rodelplayer",
} {
if strings.Contains(ua, marker) {
return true
}
}
return false
}
type embyCompatClient struct {
Client string
Device string
DeviceID string
Version string
UserID string
}
func embyCompatClientInfo(c *gin.Context) embyCompatClient {
auth := parseEmbyCompatAuthorization(firstNonEmptyString(
c.GetHeader("X-Emby-Authorization"),
c.GetHeader("X-MediaBrowser-Authorization"),
c.GetHeader("Authorization"),
))
return embyCompatClient{
Client: firstNonEmptyString(
c.GetHeader("X-Emby-Client"),
c.GetHeader("X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
auth["client"],
),
Device: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-MediaBrowser-Device-Name"),
c.Query("Device"),
c.Query("DeviceName"),
c.Query("device"),
c.Query("deviceName"),
auth["device"],
),
DeviceID: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-DeviceId"),
c.GetHeader("X-MediaBrowser-Device-Id"),
c.GetHeader("X-MediaBrowser-DeviceId"),
c.Query("DeviceId"),
c.Query("DeviceID"),
c.Query("deviceId"),
c.Query("deviceID"),
auth["deviceid"],
),
Version: firstNonEmptyString(
c.GetHeader("X-Emby-Version"),
c.GetHeader("X-MediaBrowser-Version"),
c.Query("Version"),
c.Query("version"),
auth["version"],
),
UserID: firstNonEmptyString(
c.Query("UserId"),
c.Query("userId"),
auth["userid"],
),
}
}
func parseEmbyCompatAuthorization(raw string) map[string]string {
out := map[string]string{}
raw = strings.TrimSpace(raw)
if raw == "" {
return out
}
if len(raw) >= len("MediaBrowser ") && strings.EqualFold(raw[:len("MediaBrowser ")], "MediaBrowser ") {
raw = raw[len("MediaBrowser "):]
} else if len(raw) >= len("Emby ") && strings.EqualFold(raw[:len("Emby ")], "Emby ") {
raw = raw[len("Emby "):]
}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.Trim(strings.TrimSpace(value), `"`)
if key != "" && value != "" {
out[key] = value
}
}
return out
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func sortedQueryKeys(c *gin.Context) []string {
query := c.Request.URL.Query()
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func truncateStrings(values []string, maxItems, maxLen int) []string {
if len(values) > maxItems {
values = values[:maxItems]
}
out := make([]string, 0, len(values))
for _, value := range values {
if len(value) > maxLen {
value = value[:maxLen] + "..."
}
out = append(out, value)
}
return out
}
func hasExplicitEmbyPrefix(path string) bool {
lower := strings.ToLower(strings.TrimSpace(path))
return lower == "/emby" || strings.HasPrefix(lower, "/emby/")
}
@@ -0,0 +1,124 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEmbyCompatLoggerRecordsClientAndUnimplementedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(path string) bool {
return strings.HasPrefix(path, "/emby/") || strings.HasPrefix(path, "/System/")
}))
router.GET("/emby/System/Info/Public", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ServerName": "MeBox"})
})
router.NoRoute(func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
successReq := httptest.NewRequest(http.MethodGet, "/emby/System/Info/Public?api_key=do-not-log", nil)
successReq.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Infuse", Device="Apple TV", DeviceId="device-1", Version="8.2", UserId="user-1", Token="secret-token"`)
successResp := httptest.NewRecorder()
router.ServeHTTP(successResp, successReq)
if successResp.Code != http.StatusOK {
t.Fatalf("success status = %d, want 200", successResp.Code)
}
missingReq := httptest.NewRequest(http.MethodPost, "/Search/Hints", nil)
missingReq.Header.Set("X-Emby-Token", "secret-token")
missingResp := httptest.NewRecorder()
router.ServeHTTP(missingResp, missingReq)
if missingResp.Code != http.StatusNotFound {
t.Fatalf("missing status = %d, want 404", missingResp.Code)
}
entries := logs.All()
if len(entries) != 2 {
t.Fatalf("logged entries = %d, want 2: %#v", len(entries), entries)
}
success := entries[0].ContextMap()
if success["path"] != "/emby/System/Info/Public" {
t.Fatalf("success path = %#v", success["path"])
}
if success["client"] != "Infuse" || success["device"] != "Apple TV" || success["device_id"] != "device-1" {
t.Fatalf("client fields not parsed: %#v", success)
}
if success["unimplemented"] != false {
t.Fatalf("success unimplemented = %#v, want false", success["unimplemented"])
}
if _, ok := success["token"]; ok {
t.Fatalf("token must not be logged: %#v", success)
}
if success["query_keys"] == nil {
t.Fatalf("query_keys missing: %#v", success)
}
if strings.Contains(entries[0].Message, "secret-token") {
t.Fatalf("token leaked in message: %q", entries[0].Message)
}
missing := entries[1].ContextMap()
if entries[1].Message != "emby API not implemented" {
t.Fatalf("missing message = %q", entries[1].Message)
}
if missing["path"] != "/Search/Hints" || missing["unimplemented"] != true {
t.Fatalf("missing fields = %#v", missing)
}
if missing["method"] != http.MethodPost {
t.Fatalf("missing method = %#v", missing["method"])
}
}
func TestEmbyCompatLoggerSkipsUnrelatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.GET("/api/health", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req.Header.Set("Authorization", "Bearer regular-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if logs.Len() != 0 {
t.Fatalf("unrelated request should not be logged: %#v", logs.All())
}
}
func TestEmbyCompatLoggerMarksSPAFallbackAsUnimplemented(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.NoRoute(func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("<html></html>"))
})
req := httptest.NewRequest(http.MethodGet, "/NewEmbyFeature/Test", nil)
req.Header.Set("X-Emby-Token", "secret-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("logged entries = %d, want 1", len(entries))
}
if entries[0].Message != "emby API not implemented" {
t.Fatalf("message = %q, want unimplemented warning", entries[0].Message)
}
if entries[0].ContextMap()["unimplemented"] != true {
t.Fatalf("unimplemented field missing: %#v", entries[0].ContextMap())
}
}
+6
View File
@@ -13,6 +13,12 @@ type PlaybackHistory struct {
DurationMs int64 `json:"duration_ms"`
WatchedAt time.Time `json:"watched_at"`
Completed bool `json:"completed"`
// 播放会话信息用于丢弃乱序到达的旧进度,避免旧请求把新的续播状态覆盖。
// 旧客户端不提供这些字段时保持 0,继续沿用无条件 upsert 语义。
SessionID string `gorm:"size:128;index" json:"-"`
SessionStartedAtMs int64 `gorm:"not null;default:0" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
// Favorite 将媒体项标记为给定用户的收藏。
+11 -4
View File
@@ -31,10 +31,17 @@ type User struct {
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
// 用户的开关影响其他人。
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
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"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+65 -16
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -13,24 +14,72 @@ import (
// upserts on (UserID, MediaID) so resume always reads the latest position.
type HistoryRepository struct{ db *gorm.DB }
// Upsert atomically inserts/updates the resume position in a single statement,
// relying on the uniq_user_history composite unique index. Concurrent progress
// reports for the same (user, media) can no longer double-insert.
// Upsert atomically inserts/updates the resume position in a single statement.
// Callers without playback-session metadata keep the legacy last-write-wins
// semantics (for example an explicit "mark played" action).
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
return r.upsert(ctx, h, false)
}
// UpsertProgress writes a progress report only when it is newer than the row
// currently stored. Reports from an older playback session, or an older
// sequence in the same session, are ignored. Reports without session metadata
// fall back to Upsert for compatibility with older clients.
func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.PlaybackHistory) error {
h.SessionID = strings.TrimSpace(h.SessionID)
if h.SessionID == "" || h.SessionStartedAtMs <= 0 {
return r.Upsert(ctx, h)
}
return r.upsert(ctx, h, true)
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
updates["session_id"] = h.SessionID
updates["session_started_at_ms"] = h.SessionStartedAtMs
updates["sequence"] = h.Sequence
}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(updates),
}
if versioned {
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Value: h.Sequence,
},
),
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}),
}).Create(h).Error
}}
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(h).Error
}
func playbackHistoryAssignments(h *model.PlaybackHistory) map[string]any {
return map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}
}
// ListByUser returns the most recent history rows for the user.
@@ -77,3 +77,105 @@ func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
}
}
func TestHistoryUpsertProgressRejectsStaleReports(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
watched := time.Now()
first := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: watched,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, first); err != nil {
t.Fatalf("first progress: %v", err)
}
newer := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 90_000,
DurationMs: 120_000,
WatchedAt: watched.Add(time.Minute),
Completed: true,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 2,
}
if err := repos.History.UpsertProgress(ctx, newer); err != nil {
t.Fatalf("newer progress: %v", err)
}
stale := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 10_000,
DurationMs: 120_000,
WatchedAt: watched.Add(2 * time.Minute),
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, stale); err != nil {
t.Fatalf("stale progress: %v", err)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed || got.Sequence != 2 {
t.Fatalf("stale report overwrote newer state: %#v", got)
}
oldSession := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 5_000,
DurationMs: 120_000,
WatchedAt: watched.Add(3 * time.Minute),
SessionID: "session-0",
SessionStartedAtMs: 500,
Sequence: 99,
}
if err := repos.History.UpsertProgress(ctx, oldSession); err != nil {
t.Fatalf("old session progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed {
t.Fatalf("old session overwrote newer state: %#v", got)
}
restart := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 120_000,
WatchedAt: watched.Add(4 * time.Minute),
SessionID: "session-2",
SessionStartedAtMs: 2_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, restart); err != nil {
t.Fatalf("restart progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 1_000 || got.Completed || got.SessionID != "session-2" {
t.Fatalf("new playback session did not reset state: %#v", got)
}
}
+69
View File
@@ -43,6 +43,7 @@ type MediaQueryFilter struct {
IncludeNSFW bool
AllowedLibraryIDs []string
HiddenLibraryIDs []string
SeriesID string
}
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
@@ -55,6 +56,9 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
}
return q
}
@@ -100,6 +104,67 @@ func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, li
return items, err
}
// ListAllByLibrariesFilteredNoCount loads every matching row without issuing a
// COUNT query. Full-library consumers such as series grouping must scan the
// whole library anyway, so calling it once is both cheaper and more consistent
// than issuing paginated queries with repeated counts.
func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) ([]model.Media, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, nil
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
q = applyMediaQueryFilter(q, filter)
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").Find(&items).Error
return items, err
}
// ListVersionCandidates loads a bounded candidate set for version grouping
// using the strongest identity stored on the row. Returning ok=false keeps the
// caller's full-library fallback for rows without external IDs or SeriesID.
func (r *MediaRepository) ListVersionCandidates(ctx context.Context, libraryIDs []string, media model.Media, limit int) ([]model.Media, bool, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, false, nil
}
if limit <= 0 {
limit = 5000
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
found := true
switch {
case strings.TrimSpace(media.SeriesID) != "":
q = q.Where("series_id = ?", strings.TrimSpace(media.SeriesID))
case media.TMDbID > 0:
q = q.Where("tm_db_id = ?", media.TMDbID)
case media.BangumiID > 0:
q = q.Where("bangumi_id = ?", media.BangumiID)
case strings.TrimSpace(media.DoubanID) != "":
q = q.Where("douban_id = ?", strings.TrimSpace(media.DoubanID))
case strings.TrimSpace(media.TheTVDBID) != "":
q = q.Where("thetvdb_id = ?", strings.TrimSpace(media.TheTVDBID))
default:
found = false
}
if !found {
return items, false, nil
}
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").
Limit(limit).
Find(&items).Error
return items, true, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
@@ -196,6 +261,10 @@ func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) {
parts = append(parts, "library_id IN ?")
args = append(args, filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
parts = append(parts, "series_id = ?")
args = append(args, seriesID)
}
return strings.Join(parts, " AND "), args
}
@@ -146,6 +146,9 @@ func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.D
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("media.series_id = ?", seriesID)
}
return q
}
+255 -44
View File
@@ -28,9 +28,9 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// Danmaku setting keys, managed through the admin settings UI (PUT
// /admin/settings). They are stored in the Setting table so the playback
// page can pull them without admin privileges.
// Legacy instance-level danmaku settings. Playback preferences now live on
// model.User; these keys remain only as a fallback for unauthenticated/legacy
// service paths and tests.
const (
DanmakuEnabledKey = "danmaku.enabled"
DanmakuSourceKey = "danmaku.source"
@@ -56,21 +56,55 @@ var danmakuOfficialBase = DanmakuDefaultSource
// DanmakuRenderConfig carries the renderer knobs to the web player.
type DanmakuRenderConfig struct {
Enabled bool `json:"enabled"`
Source string `json:"source,omitempty"`
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
Enabled bool `json:"enabled"`
Source string `json:"source,omitempty"`
AppID string `json:"app_id,omitempty"`
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
AppKeyConfigured bool `json:"app_key_configured"`
}
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
type DanmakuFetchOptions struct {
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
MergeSources bool
// UserID 非空时使用该用户的弹幕源、凭据与渲染偏好。
UserID string
}
// DanmakuSettingsPatch 是播放器弹幕设置的局部更新。nil 字段保持不变。
type DanmakuSettingsPatch struct {
Enabled *bool `json:"enabled"`
Source *string `json:"source"`
AppID *string `json:"app_id"`
AppKey *string `json:"app_key"`
ClearAppKey bool `json:"clear_app_key"`
Opacity *float64 `json:"opacity"`
FontSize *int `json:"font_size"`
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
}
type danmakuUserContextKey struct{}
type danmakuUserContext struct {
source string
appID string
appKey string
}
// ErrNoDanmakuSettings 表示更新请求没有包含任何可写字段。
var ErrNoDanmakuSettings = errors.New("no settings to update")
// ErrInvalidDanmakuSettings 表示更新请求包含非法值。
var ErrInvalidDanmakuSettings = errors.New("invalid player settings")
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
// comment payload; parsing happens client-side. The dandanplay protocol has
// two payload shapes in the wild — the classic Bilibili-style XML and the
@@ -197,9 +231,11 @@ type danmakuResultCacheEntry struct {
storedAt time.Time
}
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
func danmakuResultCacheKey(userID, source, credentialFingerprint, mediaID, keyword, episodeID string, merge bool) string {
return strings.Join([]string{
strings.TrimSpace(userID),
strings.TrimSpace(source),
strings.TrimSpace(credentialFingerprint),
mediaID,
strings.TrimSpace(keyword),
strings.TrimSpace(episodeID),
@@ -207,6 +243,18 @@ func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge boo
}, "\x00")
}
// danmakuCredentialFingerprint keeps credential changes in the result cache
// key without persisting the AppSecret in plain text.
func danmakuCredentialFingerprint(appID, appKey string) string {
appID = strings.TrimSpace(appID)
appKey = strings.TrimSpace(appKey)
if appID == "" && appKey == "" {
return ""
}
sum := md5.Sum([]byte(appID + "\x00" + appKey))
return hex.EncodeToString(sum[:])
}
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
if s == nil || key == "" {
return nil, false
@@ -300,6 +348,7 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -319,39 +368,164 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
return cfg
}
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
// ConfigForUser 返回当前用户的播放器与弹幕偏好。用户不存在时回退到旧版
// 全局配置,便于旧客户端和迁移期平滑工作。
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
cfg := s.Config(ctx)
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
return cfg
if user, ok := s.findUser(ctx, userID); ok {
return danmakuConfigFromUser(user)
}
return s.Config(ctx)
}
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
if s == nil || s.repo == nil || s.repo.User == nil {
return false
if user, ok := s.findUser(ctx, userID); ok {
return user.DanmakuMergeSources
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return false
}
return user.DanmakuMergeSources
return false
}
// SetMergeSources 持久化该用户的弹幕合并偏好。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.User, bool) {
if s == nil || s.repo == nil || s.repo.User == nil {
return errors.New("danmaku settings unavailable")
return nil, false
}
userID = strings.TrimSpace(userID)
if userID == "" {
return errors.New("missing user")
return nil, false
}
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
user, err := s.repo.User.FindByID(ctx, userID)
return user, err == nil && user != nil
}
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
}
opacity := user.DanmakuOpacity
if opacity < 0.1 || opacity > 1 {
opacity = 1
}
fontSize := user.DanmakuFontSize
if fontSize < 10 || fontSize > 96 {
fontSize = 24
}
area := user.DanmakuArea
if area < 0.1 || area > 1 {
area = 1
}
volume := user.PlayerVolume
if volume < 0 || volume > 1 {
volume = 1
}
return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource),
AppID: strings.TrimSpace(user.DanmakuAppID),
Opacity: strconv.FormatFloat(opacity, 'f', -1, 64),
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
}
// UpdateUserSettings 持久化当前用户的播放器与弹幕设置,并返回脱敏后的完整配置。
func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, patch DanmakuSettingsPatch) (DanmakuRenderConfig, error) {
if s == nil || s.repo == nil || s.repo.User == nil {
return DanmakuRenderConfig{}, errors.New("player settings unavailable")
}
userID = strings.TrimSpace(userID)
if userID == "" {
return DanmakuRenderConfig{}, errors.New("missing user")
}
updates := map[string]any{}
if patch.Enabled != nil {
updates["danmaku_enabled"] = *patch.Enabled
}
if patch.Source != nil {
source, err := normalizeDanmakuSource(*patch.Source)
if err != nil {
return DanmakuRenderConfig{}, err
}
updates["danmaku_source"] = source
}
if patch.AppID != nil {
appID := strings.TrimSpace(*patch.AppID)
if len(appID) > 128 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_id is too long", ErrInvalidDanmakuSettings)
}
updates["danmaku_app_id"] = appID
}
if patch.ClearAppKey {
updates["danmaku_app_key"] = ""
} else if patch.AppKey != nil {
if key := strings.TrimSpace(*patch.AppKey); key != "" {
if len(key) > 256 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_key is too long", ErrInvalidDanmakuSettings)
}
updates["danmaku_app_key"] = key
}
}
if patch.Opacity != nil {
if *patch.Opacity < 0.1 || *patch.Opacity > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: opacity must be between 0.1 and 1", ErrInvalidDanmakuSettings)
}
updates["danmaku_opacity"] = *patch.Opacity
}
if patch.FontSize != nil {
if *patch.FontSize < 10 || *patch.FontSize > 96 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: font_size must be between 10 and 96", ErrInvalidDanmakuSettings)
}
updates["danmaku_font_size"] = *patch.FontSize
}
if patch.Area != nil {
if *patch.Area < 0.1 || *patch.Area > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: area must be between 0.1 and 1", ErrInvalidDanmakuSettings)
}
updates["danmaku_area"] = *patch.Area
}
if patch.MergeSources != nil {
updates["danmaku_merge_sources"] = *patch.MergeSources
}
if patch.Volume != nil {
if *patch.Volume < 0 || *patch.Volume > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: volume must be between 0 and 1", ErrInvalidDanmakuSettings)
}
updates["player_volume"] = *patch.Volume
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil {
return DanmakuRenderConfig{}, err
}
return s.ConfigForUser(ctx, userID), nil
}
// normalizeDanmakuSource validates a user-configured dandanplay endpoint. An
// empty value means the official endpoint; only explicit HTTP(S) origins are
// accepted so malformed values never reach the outbound request layer.
func normalizeDanmakuSource(raw string) (string, error) {
source := strings.TrimRight(strings.TrimSpace(raw), "/")
if source == "" {
return "", nil
}
if len(source) > 512 {
return "", fmt.Errorf("%w: source is too long", ErrInvalidDanmakuSettings)
}
u, err := url.Parse(source)
if err != nil || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.User != nil {
return "", fmt.Errorf("%w: source must be an http(s) URL", ErrInvalidDanmakuSettings)
}
return source, nil
}
// SetMergeSources 持久化该用户的弹幕合并偏好,保留旧调用点兼容性。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
_, err := s.UpdateUserSettings(ctx, userID, DanmakuSettingsPatch{MergeSources: &enabled})
return err
}
// Fetch retrieves danmaku for the given media. keyword overrides the
@@ -388,11 +562,23 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
return nil, errors.New("danmaku service unavailable")
}
cfg := s.Config(ctx)
credentialFingerprint := ""
if user, ok := s.findUser(ctx, opts.UserID); ok {
cfg = danmakuConfigFromUser(user)
opts.MergeSources = cfg.MergeSources
credentialFingerprint = danmakuCredentialFingerprint(user.DanmakuAppID, user.DanmakuAppKey)
ctx = context.WithValue(ctx, danmakuUserContextKey{}, danmakuUserContext{
source: cfg.Source,
appID: strings.TrimSpace(user.DanmakuAppID),
appKey: strings.TrimSpace(user.DanmakuAppKey),
})
}
if !cfg.Enabled {
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
}
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
key := danmakuResultCacheKey(opts.UserID, cfg.Source, credentialFingerprint, mediaID, keyword, episodeID, opts.MergeSources)
if cached, ok := s.resultCacheGet(key); ok {
cached.DanmakuRenderConfig = cfg
return cached, nil
}
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
@@ -400,7 +586,7 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
res, err := s.fetchWithOptionsUncached(ctx, cfg, mediaID, keyword, episodeID, opts)
if err != nil {
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
return res, err
@@ -409,6 +595,9 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
return res, nil
})
res, _ := value.(*DanmakuFetchResult)
if res != nil {
res.DanmakuRenderConfig = cfg
}
if err != nil {
return cloneDanmakuFetchResult(res), err
}
@@ -416,8 +605,8 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
}
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, cfg DanmakuRenderConfig, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}
if !res.Enabled {
return res, nil
}
@@ -870,19 +1059,37 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
return string(body), nil
}
// danmakuCredentials resolves the application credentials for the official
// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
// win; otherwise the built-in obfuscated fallback pair is used. Returns
// ok=false for any other host so credentials — including the built-in pair —
// are never sent to third-party dandanplay protocol mirrors. The "official"
// host follows danmakuOfficialBase (overridable in tests).
// danmakuCredentials resolves application credentials. Per-user explicit
// credentials are used for any configured source. Legacy global credentials
// and the built-in fallback pair are only sent to the official host, never to
// third-party dandanplay protocol mirrors.
func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
u, err := url.Parse(sourceURL)
if err != nil {
return "", "", false
}
official, err := url.Parse(danmakuOfficialBase)
if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
if err != nil {
return "", "", false
}
isOfficial := strings.EqualFold(u.Hostname(), official.Hostname())
if userCtx, ok := ctx.Value(danmakuUserContextKey{}).(danmakuUserContext); ok {
userSource := strings.TrimSpace(userCtx.source)
if userSource == "" {
userSource = danmakuOfficialBase
}
if sameDanmakuBase(sourceURL, userSource) {
id, key := strings.TrimSpace(userCtx.appID), strings.TrimSpace(userCtx.appKey)
if id != "" && key != "" {
return id, key, true
}
if id != "" || key != "" {
s.log.Warn("danmaku user credentials incomplete, ignoring them",
zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
}
}
}
if !isOfficial {
return "", "", false
}
var id, key string
@@ -1216,7 +1423,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
@@ -1232,7 +1441,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
@@ -1366,9 +1577,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)
+40
View File
@@ -60,6 +60,7 @@ func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName
type danmakuSourceServer struct {
server *httptest.Server
lastSearch string // full query (anime=...&episode=...)
lastHeaders http.Header
searchResponse string // JSON body served for /api/v2/search/episodes
}
@@ -76,6 +77,7 @@ func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danm
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
ds.lastSearch = r.URL.RawQuery
ds.lastHeaders = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, ds.searchResponse)
})
@@ -404,3 +406,41 @@ func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
}
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
}
func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
user := model.User{Username: "danmaku-user", PasswordHash: "x", Role: "user", IsActive: true}
user.ID = "danmaku-user-1"
user.DanmakuEnabled = true
user.DanmakuSource = srv.URL()
user.DanmakuAppID = "user-app-id"
user.DanmakuAppKey = "user-app-secret"
user.DanmakuOpacity = 0.65
user.DanmakuFontSize = 30
user.DanmakuArea = 0.7
user.PlayerVolume = 0.42
require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
res, err := svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
require.NoError(t, err)
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
require.NotEmpty(t, srv.lastHeaders.Get("X-Signature"))
// Credential rotation must invalidate the cached fetch for the same user.
require.NoError(t, svc.repo.User.UpdateFields(ctx, user.ID, map[string]any{
"danmaku_app_id": "user-app-id-2",
"danmaku_app_key": "user-app-secret-2",
}))
_, err = svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
require.NoError(t, err)
require.Equal(t, "user-app-id-2", srv.lastHeaders.Get("X-AppId"))
}
+7
View File
@@ -21,6 +21,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
@@ -65,6 +66,12 @@ type EmbyService struct {
peopleMu sync.RWMutex
peopleCache map[string]embyPeopleCacheEntry
// 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
latestRefresh sync.Map
tmdb *TMDbProvider
adult *AdultProvider
personImageMu sync.RWMutex
+241 -50
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -120,44 +121,124 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if e == nil {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey); ok {
if stale {
e.refreshLatestItemsAsync(cacheKey, userID, parentID, limit)
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
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, 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
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
if e.cache != nil {
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
}
return out, nil
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return embyLatestCacheValue{}, err
}
cached, _ := v.(embyLatestCacheValue)
return cached, nil
}
func (e *EmbyService) refreshLatestItemsAsync(cacheKey, userID, parentID string, limit int) {
if e == nil || e.cache == nil {
return
}
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, false
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return cached.Items, nil
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{}, stale, true
}
return cached.Items, stale, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
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)
if err != nil {
return embyLatestCacheValue{}, err
}
if out == nil {
out = []map[string]any{}
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err == nil && e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
if err != nil {
return embyLatestCacheValue{}, err
}
return out, err
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
}
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
}
@@ -170,7 +251,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
rows = e.collapseMediaVersionRows(ctx, rows)
if len(rows) > limit {
@@ -178,12 +259,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
out, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, nil
return embyLatestCacheValue{Items: out}, nil
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
@@ -194,7 +272,8 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
@@ -209,6 +288,24 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
return items, e.artworkRefsForSeriesGroups(groups), nil
}
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
// 仍走完整分组。上限 2000 行。
func embyLatestSeriesRowLimit(limit int) int {
if limit < 1 {
limit = 20
}
n := limit * 40
if n < 200 {
n = 200
}
if n > 2000 {
n = 2000
}
return n
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
@@ -393,7 +490,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
items := make([]map[string]any, 0, p.Limit)
slots := make([]resumeSlot, 0, len(hist))
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
@@ -401,36 +498,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
}
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
remoteTotal++
if len(items) >= needed {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
}
for {
filled := 0
pending := make([]*resumeRemoteFetch, 0)
for i := range slots {
if slots[i].item != nil {
filled++
if filled >= needed {
break
}
continue
}
f := slots[i].fetch
if f == nil || f.done {
continue
}
if f.mount == nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
f.done = true
continue
}
f.mount, f.acct, f.remoteID = mount, acct, remoteID
}
pending = append(pending, f)
if filled+len(pending) >= needed {
break
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
items = append(items, item)
if len(pending) == 0 {
break
}
e.fillResumeRemotes(ctx, pending)
for i := range slots {
f := slots[i].fetch
if f == nil || !pendingContains(pending, f) {
continue
}
f.done = true
item := f.item
f.item = nil
if item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
slots[i].item = item
}
}
items := make([]map[string]any, 0, p.Limit)
for i := range slots {
if slots[i].item == nil {
continue
}
items = append(items, slots[i].item)
}
total := int64(localTotal + remoteTotal)
@@ -441,6 +579,57 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
type resumeSlot struct {
item map[string]any
fetch *resumeRemoteFetch
}
type resumeRemoteFetch struct {
hist model.PlaybackHistory
mount *model.EmbyMount
acct *model.StrmAccount
remoteID string
item map[string]any
done bool
}
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
for _, row := range rows {
if row == target {
return true
}
}
return false
}
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
if e == nil || e.remote == nil || len(rows) == 0 {
return
}
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
if err != nil || item == nil {
return
}
rows[i].item = item
}(i)
}
wg.Wait()
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
itemType := "Movie"
name := m.Title
@@ -448,10 +637,12 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
seasonNum := m.SeasonNum
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonNum = embySeasonNumForMedia(m)
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
@@ -496,7 +687,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum,
"ParentIndexNumber": seasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": runTimeTicks,
@@ -508,7 +699,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"Path": m.Path,
"ParentId": parentID,
"SeasonId": seasonID,
"SeasonName": seasonName(m.SeasonNum),
"SeasonName": seasonName(seasonNum),
"SeriesId": seriesID,
"SeriesName": seriesName,
"ImageTags": imageTags,
+2 -2
View File
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
return out, nil
}
m, err := e.playableMedia(ctx, mediaID, userID)
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
return map[string]any{
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
}, nil
}
+29 -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
@@ -805,6 +813,15 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
// RemoteItem 拉取远程单条目详情(含响应的重写)。
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("item", mount.ID, remoteID)
var cached map[string]any
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
r.rememberRemotePeople(mount, cached)
return cached, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
@@ -817,6 +834,9 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
}
r.rememberRemotePeople(mount, out)
RewriteEmbyRemoteIDs(out, mount.ID)
if cacheKey != "" && len(out) > 0 {
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
}
return out, nil
}
@@ -1109,7 +1129,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 != "" {
@@ -1269,12 +1289,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
}
+17
View File
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
// 供调用方免二次请求读取 Type / SeriesId 等字段。
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
var cached struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
return cached.Media, cached.Raw, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, nil, err
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
}
RewriteEmbyRemoteIDs(out, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
if cacheKey != "" {
r.cache.SetJSON(ctx, cacheKey, struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
}
return &m, out, nil
}
+9 -7
View File
@@ -143,8 +143,11 @@ func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id,
}
matched := make([]string, 0, 1)
for _, cand := range candidates {
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
matched = append(matched, cand.SeriesID)
for _, seasonNum := range embySeasonCandidates(cand.SeasonNum) {
if seasonID(cand.SeriesID, seasonNum) == id {
matched = append(matched, cand.SeriesID)
break
}
}
}
for _, matchedSeries := range matched {
@@ -257,10 +260,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
bySeason := map[int]*embySeasonGroup{}
order := []int{}
for _, episode := range series.Episodes {
seasonNum := episode.SeasonNum
if seasonNum < 0 {
seasonNum = 1
}
seasonNum := embySeasonNumForMedia(&episode)
season, ok := bySeason[seasonNum]
if !ok {
season = &embySeasonGroup{
@@ -276,7 +276,9 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
}
season.Episodes = append(season.Episodes, episode)
}
sort.Ints(order)
sort.SliceStable(order, func(i, j int) bool {
return embySeasonSortOrder(order[i]) < embySeasonSortOrder(order[j])
})
out := make([]embySeasonGroup, 0, len(order))
for _, seasonNum := range order {
out = append(out, *bySeason[seasonNum])
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"sort"
"testing"
"time"
@@ -156,6 +157,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) {
}
}
func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) {
if got := embyLatestSeriesRowLimit(25); got < 25*40 {
t.Fatalf("latest window %d is smaller than the 25x40 fixture", got)
}
}
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
@@ -209,6 +216,147 @@ func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
}
}
func TestEmbySeparatesOVAAndOADFromSeasonOne(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "动漫", Path: `/media/影视库/动漫`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for _, media := range []model.Media{
{
Base: model.Base{ID: "tolove-s01e01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru S01E01.mkv.strm`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "tolove-ova01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru [OVA01].mkv.strm`,
SeasonNum: 0,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "tolove-oad01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/OAD/To LOVE-Ru Darkness [OAD01].mkv.strm`,
SeasonNum: -1,
EpisodeNum: 1,
},
} {
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50})
if err != nil {
t.Fatalf("library items: %v", err)
}
rootItems := root["Items"].([]map[string]any)
if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" {
t.Fatalf("expected one series card, got %#v", rootItems)
}
if rootItems[0]["ChildCount"] != 3 {
t.Fatalf("series ChildCount = %#v, want 3", rootItems[0]["ChildCount"])
}
seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50})
if err != nil {
t.Fatalf("series seasons: %v", err)
}
seasonItems := seasons["Items"].([]map[string]any)
if len(seasonItems) != 3 {
t.Fatalf("expected season 1, OVA and OAD separately, got %#v", seasonItems)
}
for i, want := range []int{1, embySeasonOVA, embySeasonOAD} {
if seasonItems[i]["IndexNumber"] != want {
t.Fatalf("season order [%d] = %#v, want %d", i, seasonItems[i], want)
}
}
seasonByIndex := make(map[int]map[string]any, len(seasonItems))
for _, season := range seasonItems {
index, ok := season["IndexNumber"].(int)
if !ok {
t.Fatalf("season index has unexpected type: %#v", season)
}
seasonByIndex[index] = season
}
for index, name := range map[int]string{
1: "第 1 季",
embySeasonOVA: "OVA",
embySeasonOAD: "OAD",
} {
season := seasonByIndex[index]
if season == nil || season["Name"] != name || season["ChildCount"] != 1 {
t.Fatalf("season %d = %#v, want name=%q with one episode", index, season, name)
}
episodes, err := svc.Items(t.Context(), ItemsParams{
ParentID: season["Id"].(string),
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("season %d episodes: %v", index, err)
}
episodeItems := episodes["Items"].([]map[string]any)
if len(episodeItems) != 1 || episodeItems[0]["ParentIndexNumber"] != index {
t.Fatalf("season %d episodes = %#v", index, episodeItems)
}
}
}
func TestEmbySeasonSortOrderMatchesWeb(t *testing.T) {
seasons := []int{
embySeasonNCED,
embySeasonOAD,
2,
embySeasonTheatrical,
1,
embySeasonGenericSpecial,
embySeasonOVA,
}
sort.SliceStable(seasons, func(i, j int) bool {
return embySeasonSortOrder(seasons[i]) < embySeasonSortOrder(seasons[j])
})
want := []int{
1,
2,
embySeasonGenericSpecial,
embySeasonTheatrical,
embySeasonOVA,
embySeasonOAD,
embySeasonNCED,
}
for i := range want {
if seasons[i] != want[i] {
t.Fatalf("season order = %#v, want %#v", seasons, want)
}
}
}
func TestEmbySeasonCandidatesCoverPersistedSpecialNumbers(t *testing.T) {
for _, persisted := range []int{0, -1} {
candidates := embySeasonCandidates(persisted)
found := map[int]bool{}
for _, seasonNum := range candidates {
found[seasonNum] = true
}
if !found[embySeasonOVA] || !found[embySeasonOAD] {
t.Fatalf("candidates for persisted season %d = %#v, want OVA and OAD", persisted, candidates)
}
}
if candidates := embySeasonCandidates(2); len(candidates) != 1 || candidates[0] != 2 {
t.Fatalf("regular season candidates = %#v, want [2]", candidates)
}
}
func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
+135 -8
View File
@@ -22,7 +22,7 @@ func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) stri
}
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
return seasonID(e.seriesIDForMedia(ctx, m), embySeasonNumForMedia(m))
}
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
@@ -100,23 +100,150 @@ func stableEmbyID(prefix string, parts ...string) string {
return prefix + hex.EncodeToString(h.Sum(nil))[:32]
}
func seasonID(seriesID string, seasonNum int) string {
if seasonNum < 0 {
seasonNum = 1
const (
embySeasonTheatrical = -1
embySeasonOVA = -2
embySeasonOAD = -3
embySeasonOVD = -4
embySeasonONA = -5
embySeasonExtra = -6
embySeasonBonus = -7
embySeasonOmake = -8
embySeasonPictureDrama = -9
embySeasonNCOP = -10
embySeasonNCED = -11
embySeasonGenericSpecial = 0
)
// embySeasonNumForMedia keeps MeBox's special categories distinct in the Emby
// hierarchy. Databases may store every special as season 0 or -1, so the path
// classification is authoritative when it identifies a special category.
func embySeasonNumForMedia(m *model.Media) int {
if m == nil {
return 0
}
switch mediaSpecialKind(m.Path) {
case mediaSpecialTheatrical:
return embySeasonTheatrical
case mediaSpecialOVA:
return embySeasonOVA
case mediaSpecialOAD:
return embySeasonOAD
case mediaSpecialOVD:
return embySeasonOVD
case mediaSpecialONA:
return embySeasonONA
case mediaSpecialExtra:
return embySeasonExtra
case mediaSpecialBonus:
return embySeasonBonus
case mediaSpecialOmake:
return embySeasonOmake
case mediaSpecialPicture:
return embySeasonPictureDrama
case mediaSpecialNCOP:
return embySeasonNCOP
case mediaSpecialNCED:
return embySeasonNCED
case mediaSpecialGeneric:
return embySeasonGenericSpecial
}
if m.SeasonNum < 0 {
return embySeasonGenericSpecial
}
return m.SeasonNum
}
func embySeasonCandidates(seasonNum int) []int {
if seasonNum > 0 {
return []int{seasonNum}
}
return []int{
embySeasonGenericSpecial,
embySeasonTheatrical,
embySeasonOVA,
embySeasonOAD,
embySeasonOVD,
embySeasonONA,
embySeasonExtra,
embySeasonBonus,
embySeasonOmake,
embySeasonPictureDrama,
embySeasonNCOP,
embySeasonNCED,
}
}
func seasonID(seriesID string, seasonNum int) string {
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
}
func seasonName(seasonNum int) string {
if seasonNum == 0 {
switch seasonNum {
case embySeasonGenericSpecial:
return "特别篇"
}
if seasonNum < 0 {
seasonNum = 1
case embySeasonTheatrical:
return "剧场版"
case embySeasonOVA:
return "OVA"
case embySeasonOAD:
return "OAD"
case embySeasonOVD:
return "OVD"
case embySeasonONA:
return "ONA"
case embySeasonExtra:
return "Extra"
case embySeasonBonus:
return "Bonus"
case embySeasonOmake:
return "Omake"
case embySeasonPictureDrama:
return "Picture Drama"
case embySeasonNCOP:
return "NCOP"
case embySeasonNCED:
return "NCED"
}
return fmt.Sprintf("第 %d 季", seasonNum)
}
// embySeasonSortOrder mirrors the web UI's seasonSortOrder: regular seasons
// first in ascending order, followed by specials in a stable category order.
func embySeasonSortOrder(seasonNum int) int {
if seasonNum > 0 {
return seasonNum
}
switch seasonNum {
case embySeasonGenericSpecial:
return 1000
case embySeasonTheatrical:
return 1001
case embySeasonOVA:
return 1002
case embySeasonOAD:
return 1003
case embySeasonOVD:
return 1004
case embySeasonONA:
return 1005
case embySeasonExtra:
return 1006
case embySeasonBonus:
return 1007
case embySeasonOmake:
return 1008
case embySeasonPictureDrama:
return 1009
case embySeasonNCOP:
return 1010
case embySeasonNCED:
return 1011
default:
return 2000
}
}
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
switch primarySupportedEmbySort(p.SortBy, false) {
case "sortname", "name":
+42 -8
View File
@@ -63,7 +63,15 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
return e.RecordProgressWithSession(ctx, userID, mediaID, positionTicks, runtimeTicks, "")
}
// RecordProgressWithSession records an Emby progress update together with its
// PlaySessionId. The server-issued ID contains a millisecond timestamp, which
// lets the repository reject reports from an older playback session.
func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64, playSessionID string) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
@@ -89,14 +97,16 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
}
}
}
completed := dur > 0 && pos >= dur*9/10
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
playSessionID = strings.TrimSpace(playSessionID)
err := e.repo.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(pos, dur),
SessionID: playSessionID,
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
})
if err == nil {
e.invalidateEmbyItemsCache(ctx)
@@ -104,6 +114,30 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
return err
}
// embyPlaySessionStartedAtMs extracts the millisecond timestamp embedded in a
// MeBox-issued PlaySessionId. Older IDs used seconds, so normalize those too.
func embyPlaySessionStartedAtMs(playSessionID string) int64 {
playSessionID = strings.TrimSpace(playSessionID)
if playSessionID == "" {
return 0
}
idx := strings.LastIndex(playSessionID, "-")
if idx < 0 || idx == len(playSessionID)-1 {
return 0
}
value, err := strconv.ParseInt(playSessionID[idx+1:], 10, 64)
if err != nil || value <= 0 {
return 0
}
if value >= 1_000_000_000 && value < 1_000_000_000_000 {
value *= 1000
}
if value < 1_000_000_000_000 {
return 0
}
return value
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
+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
}
+7 -5
View File
@@ -24,17 +24,19 @@ import (
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
)
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
fetchGroup singleflight.Group
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
+37 -3
View File
@@ -95,7 +95,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath)
if err != nil {
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
@@ -196,14 +196,48 @@ 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 {
data []byte
contentType string
contentLength string
}
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
// upstream image. A poster can appear in the hero, a shelf and the detail page
// at the same time; without this guard every resize variant may fetch the same
// original before the first cache write finishes.
func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) {
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath)
if err != nil {
return nil, err
}
return sharedRemoteImageResult{
data: data,
contentType: contentType,
contentLength: contentLength,
}, nil
})
if err != nil {
return nil, "", "", err
}
result, ok := value.(sharedRemoteImageResult)
if !ok {
return nil, "", "", errors.New("upstream image fetch failed")
}
return result.data, result.contentType, result.contentLength, nil
}
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
+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")
+71 -1
View File
@@ -226,6 +226,34 @@ func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
}
}
// tryAcquireResizeSlot 不等待。槽位忙时返回 false,调用方应改出原图。
func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
if p == nil {
return func() {}, true
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
default:
return nil, false
}
}
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
if size > 0 && size <= resizeQueueBypassBytes {
return p.tryAcquireResizeSlot()
}
return p.acquireResizeSlot(ctx)
}
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
@@ -241,8 +269,13 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
// 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的
// 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。
if serveCompactOriginal(w, r, srcPath, stat) {
return true
}
release, ok := p.acquireResizeSlot(r.Context())
release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size())
if !ok {
return false
}
@@ -273,6 +306,43 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
const compactImageSkipBytes = 200 * 1024
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
// 以免把未压缩的剧照直接发给客户端。
const resizeQueueBypassBytes = 1536 * 1024
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
// 跳过解码。返回 false 表示仍应走缩放路径。
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
if stat == nil || stat.Size() <= 0 || stat.Size() > compactImageSkipBytes {
return false
}
file, err := os.Open(srcPath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
if err != nil {
return false
}
var header [12]byte
n, _ := file.Read(header[:])
_ = file.Close()
if !isCompactWebImage(header[:n]) {
return false
}
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
}
func isCompactWebImage(header []byte) bool {
if len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff {
return true
}
return len(header) >= 12 &&
string(header[0:4]) == "RIFF" &&
string(header[8:12]) == "WEBP"
}
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
dir := filepath.Dir(cachePath)
+60
View File
@@ -2,12 +2,15 @@ package service
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"net/http/httptest"
"os"
"testing"
"time"
)
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
@@ -274,3 +277,60 @@ func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
t.Fatal("expected cached thumbnail to be reused without decoding the source")
}
}
func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "poster.jpg"
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 800, 1200)), &jpeg.Options{Quality: 40}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
if buf.Len() == 0 || buf.Len() > compactImageSkipBytes {
t.Fatalf("test jpeg is %d bytes, want a compact poster", buf.Len())
}
if err := os.WriteFile(src, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
rec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=120", nil), src, imageResizeOptions{MaxWidth: 120}) {
t.Fatal("expected compact jpeg to be served")
}
if !bytes.Equal(rec.Body.Bytes(), buf.Bytes()) {
t.Fatal("expected the original jpeg, not a decoded thumbnail")
}
}
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "backdrop.jpg"
data := make([]byte, compactImageSkipBytes+1024)
data[0], data[1], data[2] = 0xff, 0xd8, 0xff
if err := os.WriteFile(src, data, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
if !ok {
t.Fatal("expected a resize slot")
}
releases = append(releases, release)
}
defer func() {
for _, release := range releases {
release()
}
}()
start := time.Now()
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
if time.Since(start) > 200*time.Millisecond {
t.Fatal("resize slot was busy but the request still waited in the queue")
}
if handled {
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
}
}
@@ -101,7 +101,21 @@ func localBackdropCandidates(mediaPath string) []string {
seen[key] = struct{}{}
names = append(names, name)
}
_, episode := ParseEpisode(mediaPath)
for _, base := range mediaSidecarBaseVariants(mediaPath) {
// Jellyfin/Emby commonly stores an episode still beside the video as
// "<episode>-thumb.jpg" or "<episode>.jpg". Episode-specific artwork
// must win over a series-level fanart.jpg in the parent folder,
// otherwise a rescan flattens every episode to the same backdrop.
add(base + "-thumb")
add(base + ".thumb")
if episode > 0 {
add(base + "-still")
add(base + ".still")
add(base + "-scene")
add(base + ".scene")
add(base)
}
add(base + "-fanart")
add(base + ".fanart")
add(base + "-backdrop")
+2 -2
View File
@@ -236,8 +236,8 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if got.PosterURL != showPoster {
t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
}
if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
if got.BackdropURL != episodeThumb {
t.Fatalf("BackdropURL = %q, want episode thumb %q, not series backdrop %q", got.BackdropURL, episodeThumb, backdrop)
}
}
+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")
}
}
+7 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
@@ -11,10 +12,12 @@ import (
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
}
type MediaVisibility struct {
+42 -5
View File
@@ -31,11 +31,12 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:list:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string {
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
libIDs := make([]string, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
@@ -48,9 +49,10 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim
sum := sha1.Sum([]byte(strings.Join([]string{
"preview",
strings.Join(libIDs, ","),
fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW),
fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:preview:" + hex.EncodeToString(sum[:])
}
@@ -69,6 +71,24 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
return "media:series-cards:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter repository.MediaQueryFilter) string {
ids := append([]string(nil), libraryIDs...)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(ids)
sort.Strings(allowed)
sort.Strings(hidden)
sum := sha1.Sum([]byte(strings.Join([]string{
"counts",
strings.Join(ids, ","),
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:lib-counts:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) mediaCacheTTLSeconds() int {
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
return 90
@@ -82,25 +102,42 @@ func (s *MediaService) mediaObjectTTL() time.Duration {
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
}
// derivedReadCacheTTL 给首页预览、剧集分组、库计数这类派生读缓存一个更长的
// 下限。扫描/刮削/删改都会走 invalidateMediaCache 清掉 media: 前缀,所以
// 拉长 TTL 不会在写入后继续吐旧卡片。配置里的 TTL 更长时仍以配置为准。
func (s *MediaService) derivedReadCacheTTL() time.Duration {
ttl := s.mediaObjectTTL()
const floor = 10 * time.Minute
if ttl < floor {
return floor
}
return ttl
}
func hashObjectCacheKey(parts []string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(sum[:])
}
// mediaGroupedRowsCacheKey 版本分组源行(已挂库元数据)的对象缓存。
func (s *MediaService) mediaGroupedRowsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
// groupedItemsCacheKey caches the fully grouped version list. Version grouping
// requires scanning every row in the library, so recomputing it for each page
// request makes pagination O(pages × library size) instead of O(library size).
// Only the grouped result is cached; caching the ungrouped rows as well would
// duplicate large libraries in the process memory for no additional hit path.
func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return "media:obj:grouped-rows:" + hashObjectCacheKey([]string{
return "media:obj:grouped-items:" + hashObjectCacheKey([]string{
libraryID,
strings.Join(libs, ","),
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
})
}
+51 -23
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/model"
@@ -12,14 +13,12 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
return
}
libs, err := s.repo.Library.List(ctx)
libs, err := s.displayLibraries(ctx)
if err != nil {
return
}
byID := make(map[string]model.Library, len(libs))
for i := range libs {
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
lib := libs[i]
for _, lib := range libs {
byID[lib.ID] = lib
}
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
@@ -44,11 +43,39 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
}
}
func (s *MediaService) displayLibraries(ctx context.Context) ([]model.Library, error) {
const cacheKey = "media:obj:library-metadata"
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := cachedObj.([]model.Library); ok {
return cached, nil
}
}
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
for i := range libs {
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
}
if s.cache != nil {
s.cache.SetObject(cacheKey, libs, s.mediaObjectTTL())
}
return libs, nil
}
type mediaDisplayLibraryResolver struct {
byID map[string]model.Library
displayByID map[string]model.Library
displayByMergeKey map[string]model.Library
displayLibraries []model.Library
localDisplays []mediaLocalDisplayLibrary
}
type mediaLocalDisplayLibrary struct {
library model.Library
path string
}
func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
@@ -58,6 +85,7 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
displayByID: make(map[string]model.Library, len(displayLibraries)),
displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
displayLibraries: displayLibraries,
localDisplays: make([]mediaLocalDisplayLibrary, 0, len(displayLibraries)),
}
for _, lib := range libs {
normalized := normalizeLocalLibraryPathForDisplay(lib)
@@ -70,7 +98,23 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
resolver.displayByMergeKey[key] = lib
}
}
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
displayPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
if displayPath == "" || displayPath == "." {
continue
}
resolver.localDisplays = append(resolver.localDisplays, mediaLocalDisplayLibrary{
library: lib,
path: displayPath,
})
}
// 最长路径优先:一次命中就是原逻辑中最具体的媒体库,避免每条媒体都
// 重新规范化全部库路径并扫描整个库列表。
sort.SliceStable(resolver.localDisplays, func(i, j int) bool {
return len(resolver.localDisplays[i].path) > len(resolver.localDisplays[j].path)
})
return resolver
}
@@ -172,26 +216,10 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (
if isRelativeVolumeMarkerPath(media.Path) {
mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path))
}
var best model.Library
bestLen := 0
for _, lib := range r.displayLibraries {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
for _, display := range r.localDisplays {
if mediaPath == display.path || strings.HasPrefix(mediaPath, strings.TrimRight(display.path, "/")+"/") {
return display.library, true
}
libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
if libPath == "" || libPath == "." {
continue
}
if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
continue
}
if len(libPath) > bestLen {
best = lib
bestLen = len(libPath)
}
}
if bestLen > 0 {
return best, true
}
return model.Library{}, false
}
@@ -0,0 +1,43 @@
package service
import (
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestMediaDisplayLibraryResolverUsesMostSpecificPath(t *testing.T) {
root := t.TempDir()
parentPath := filepath.Join(root, "media")
nestedPath := filepath.Join(parentPath, "anime")
parent := model.Library{
Base: model.Base{ID: "parent"},
Name: "媒体",
Path: parentPath,
Enabled: true,
}
nested := model.Library{
Base: model.Base{ID: "nested"},
Name: "动漫",
Path: nestedPath,
Enabled: true,
}
resolver := newMediaDisplayLibraryResolver(t.Context(), nil, []model.Library{parent, nested})
got, ok := resolver.DisplayLibraryForMedia(model.Media{
LibraryID: parent.ID,
Path: filepath.Join(nestedPath, "Show", "S01E01.mkv"),
})
if !ok || got.ID != nested.ID {
t.Fatalf("display library = %q, %t; want nested library", got.ID, ok)
}
got, ok = resolver.DisplayLibraryForMedia(model.Media{
LibraryID: parent.ID,
Path: filepath.Join(parentPath, "Movie", "Movie.mkv"),
})
if !ok || got.ID != parent.ID {
t.Fatalf("display library = %q, %t; want parent library", got.ID, ok)
}
}
+94 -46
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -15,17 +14,49 @@ type LibraryPreviewItem struct {
Cards []SeriesCard `json:"cards"`
}
type libraryPreviewCacheValue struct {
Items []LibraryPreviewItem `json:"items"`
}
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
}
// CountLibrariesCached returns per-library media totals. The homepage metadata
// request asks for every library, and the underlying COUNT is repeated on each
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
if len(libraryIDs) == 0 {
return map[string]int64{}, nil
}
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
var cached map[string]int64
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
return cached, nil
}
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
if err != nil {
return nil, err
}
if counts == nil {
counts = map[string]int64{}
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
}
return counts, nil
}
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
}
// ListLibraryPreviews returns only the latest preview cards. The metadata
// endpoint already returns totals, so preview batches used by the home and
// library pages can skip an otherwise repeated COUNT(*) over every library.
func (s *MediaService) ListLibraryPreviews(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, false)
}
func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int, includeCounts bool) ([]LibraryPreviewItem, error) {
if cardLimit <= 0 {
cardLimit = 10
}
@@ -33,45 +64,65 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
if len(libraries) == 0 {
return out, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter)
var cached libraryPreviewCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
}
libIDs := make([]string, 0, len(libraries))
pending := make([]model.Library, 0, len(libraries))
pendingSet := make(map[string]struct{}, len(libraries))
for i, lib := range libraries {
out[i] = LibraryPreviewItem{
Library: lib,
Total: 0,
Cards: []SeriesCard{},
}
libIDs = append(libIDs, lib.ID)
itemKey := s.libraryPreviewCacheKey([]model.Library{lib}, cardLimit, filter, includeCounts)
if s.cache != nil && s.cache.GetJSON(ctx, itemKey, &out[i]) {
continue
}
pending = append(pending, lib)
pendingSet[lib.ID] = struct{}{}
}
if len(pending) == 0 {
return out, nil
}
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
if err != nil {
return nil, err
libIDs := make([]string, len(pending))
for i := range pending {
libIDs[i] = pending[i].ID
}
for i := range out {
if total, ok := counts[out[i].ID]; ok {
out[i].Total = total
if includeCounts {
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
if err != nil {
return nil, err
}
for i := range out {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
out[i].Total = counts[out[i].ID]
}
}
fetchCount := cardLimit * 4
if fetchCount < 60 {
fetchCount = 60
} else if fetchCount > 200 {
fetchCount = 200
// Preview rows are only an internal candidate window. Keep it bounded so a
// library with a very long series cannot turn a homepage request into a full
// 50k-row scan merely to find another distinct card.
fetchCount := cardLimit * 12
minFetchCount := 120
if cardLimit <= 2 {
minFetchCount = 24
} else if cardLimit <= 4 {
// 首页/媒体库入口的马赛克只需要少量代表图,没必要为暂时不会
// 展示的横向货架扫描一整批 120 行候选。
minFetchCount = 48
}
if fetchCount < minFetchCount {
fetchCount = minFetchCount
} else if fetchCount > 400 {
fetchCount = 400
}
recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter)
@@ -81,7 +132,7 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
for i := range out {
if out[i].Total == 0 {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
@@ -93,32 +144,29 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
s.attachLibraryMetadata(ctx, allPreviewItems)
for i := range out {
if out[i].Total == 0 {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
continue
}
cards := groupMediaSeriesCards(items)
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
cards = fullCards
}
}
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
cards := groupMediaSeriesCards(items)
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
}
return out, nil
+54
View File
@@ -155,3 +155,57 @@ func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) {
t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards))
}
}
func TestListLibraryPreviewsSkipsTotals(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "预览电影",
Path: "/media/movies/预览电影.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
previews, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10)
if err != nil {
t.Fatal(err)
}
if len(previews) != 1 || previews[0].Total != 0 || len(previews[0].Cards) != 1 {
t.Fatalf("preview-only result = %#v, want one card and no count", previews)
}
}
func TestListLibraryPreviewsCachesEachLibraryIndependently(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "预览电影",
Path: "/media/movies/预览电影.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
visibility := MediaVisibility{IncludeNSFW: true}
if _, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, visibility, 10); err != nil {
t.Fatal(err)
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
key := svc.libraryPreviewCacheKey([]model.Library{lib}, 10, filter, false)
var cached LibraryPreviewItem
if !svc.cache.GetJSON(t.Context(), key, &cached) || len(cached.Cards) != 1 {
t.Fatalf("per-library preview cache = %#v, want one cached card", cached)
}
}
+57 -20
View File
@@ -56,14 +56,56 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
grouped := groupMediaVersions(items)
return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil
}
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
}
}
}
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
}
return grouped, nil
})
if err != nil {
return nil, err
}
if grouped, ok := value.([]MediaItem); ok {
return grouped, nil
}
return nil, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
@@ -75,30 +117,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.mediaGroupedRowsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := cachedObj.([]model.Media); ok {
// 对象缓存中的切片视为不可变;attachLibraryMetadata 会在填充时
// 执行过,命中路径直接返回副本即可(调用方只读)。
return cached, nil
}
}
}
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
if err != nil {
return nil, err
}
if total > int64(len(items)) && s.log != nil {
s.log.Warn("media version grouping truncated by safety limit",
if len(items) >= maxMediaSearchLimit && s.log != nil {
s.log.Warn("media version grouping may be truncated by safety limit",
zap.String("library_id", libraryID),
zap.Int64("total", total),
zap.Int("limit", maxMediaSearchLimit))
}
s.attachLibraryMetadata(ctx, items)
if s.cache != nil {
s.cache.SetObject(cacheKey, items, s.mediaObjectTTL())
}
return items, nil
}
@@ -148,10 +179,16 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
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)
if err != nil {
return nil, err
}
}
s.attachLibraryMetadata(ctx, candidates)
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
+168 -75
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
type seriesCardsCacheValue struct {
@@ -24,6 +25,7 @@ type libraryRowsCacheValue struct {
Rows []model.Media
Resolver mediaSeriesKeyResolver
Episodes map[string][]model.Media
Cards []SeriesCard
}
type SeriesCard struct {
@@ -34,6 +36,35 @@ type SeriesCard struct {
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
// SeriesCardView is the compact payload used by homepage and library preview
// endpoints. LinkMedia is only needed to resolve the target library ID, so
// sending the full media row twice roughly doubles the preview JSON for no UI
// benefit.
type SeriesCardView struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
if len(cards) == 0 {
return []SeriesCardView{}
}
out := make([]SeriesCardView, len(cards))
for i, card := range cards {
out[i] = SeriesCardView{
Key: card.Key,
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
LastAddedAt: card.LastAddedAt,
}
}
return out
}
type seriesCardGroup struct {
card SeriesCard
latest time.Time
@@ -41,38 +72,50 @@ type seriesCardGroup struct {
// libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
value, err, _ := s.libraryRowsFlight.Do(cacheKey, func() (any, error) {
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
}
}
}
}
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
rows, _, err := s.listAllMediaVisible(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
})
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver := newMediaSeriesKeyResolver(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for _, row := range rows {
k := resolver.key(row)
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
if cached, ok := value.(*libraryRowsCacheValue); ok {
return cached, nil
}
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
}
return value, nil
return nil, nil
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryCardsObjectKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
@@ -85,13 +128,18 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
if err != nil {
return nil, 0, err
}
cards := groupMediaSeriesCards(rows.Rows)
cards := rows.Cards
if cards == nil && len(rows.Rows) > 0 {
// Fallback keeps rolling-cache compatibility if an older runtime cache
// value was created before Cards was added.
cards = groupMediaSeriesCards(rows.Rows)
}
if cards == nil {
cards = []SeriesCard{}
}
total := int64(len(cards))
if s.cache != nil {
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
}
return cards, total, nil
}
@@ -171,6 +219,30 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if target.LibraryID == "" {
return []model.Media{*target}, nil
}
// 电影、音乐等单条目库不需要为了返回自身而加载整库。详情页会并行
// 请求 /media/:id/episodes,未短路时每次冷缓存都会触发一次全库分组。
if lib, err := s.repo.Library.FindByID(ctx, target.LibraryID); err == nil && lib != nil &&
libraryUsesSingleMediaRows(lib.Type) &&
strings.TrimSpace(target.SeriesID) == "" &&
!mediaLooksEpisodicForGrouping(*target) {
return []model.Media{*target}, nil
}
if seriesID := strings.TrimSpace(target.SeriesID); seriesID != "" {
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, target.LibraryID)
if err == nil && len(libraryIDs) > 0 {
rows, queryErr := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
SeriesID: seriesID,
})
if queryErr == nil && len(rows) > 1 {
s.attachLibraryMetadata(ctx, rows)
sortEpisodesForDisplay(rows)
return rows, nil
}
}
}
cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility)
if err != nil {
return nil, err
@@ -190,40 +262,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
}
}
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if len(dirMatches) > 1 {
out = dirMatches
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(out) == 0 {
out = []model.Media{*target}
@@ -245,36 +317,57 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
return out, nil
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
const pageSize = 2000
var all []model.Media
var total int64
for page := 1; ; page++ {
rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility)
if err != nil {
return nil, 0, err
}
if page == 1 {
total = n
all = make([]model.Media, 0, minInt64(n, pageSize))
}
all = append(all, rows...)
if int64(len(all)) >= n || len(rows) < pageSize {
break
}
func libraryUsesSingleMediaRows(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies", "music", "adult":
return true
default:
return false
}
return all, total, nil
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, 0, err
}
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
rows, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
return rows, int64(len(rows)), nil
}
func groupMediaSeriesCards(items []model.Media) []SeriesCard {
if len(items) == 0 {
return nil
}
_, keys := resolveMediaSeriesKeys(items)
return groupMediaSeriesCardsByKeys(items, keys)
}
// groupMediaSeriesCardsByKeys performs the card fold using already-resolved
// keys. Full-library caches need the same keys for the episode index and the
// series-card list; resolving them once avoids several regex-heavy passes over
// every episode row.
func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCard {
if len(items) == 0 {
return nil
}
if len(keys) != len(items) {
return groupMediaSeriesCards(items)
}
groups := make([]seriesCardGroup, 0)
byKey := make(map[string]int, len(items))
resolver := newMediaSeriesKeyResolver(items)
for _, item := range items {
key := resolver.key(item)
for i, item := range items {
key := keys[i]
if key == "" {
continue
}
+84 -36
View File
@@ -14,75 +14,123 @@ type mediaSeriesKeyResolver struct {
pathTitles map[string]string
}
// mediaSeriesKeyInputs 保存单条媒体解析剧集 key 所需的派生字段。剧集卡片、
// 剧集索引和分页分组需要反复读取同一批行;提前计算一次可以避免对每行执行
// 多轮正则匹配、路径解析和标题规范化。
type mediaSeriesKeyInputs struct {
episodic bool
pathKey string
externalKey string
titleKey string
}
func analyzeMediaSeriesKey(item model.Media) mediaSeriesKeyInputs {
if !mediaLooksEpisodicForGrouping(item) {
return mediaSeriesKeyInputs{}
}
return mediaSeriesKeyInputs{
episodic: true,
pathKey: mediaSeriesRawKey(item),
externalKey: repeatedSeriesExternalKey(item),
titleKey: repeatedSeriesTitleKey(item),
}
}
func analyzeMediaSeriesKeys(items []model.Media) []mediaSeriesKeyInputs {
inputs := make([]mediaSeriesKeyInputs, len(items))
for i := range items {
inputs[i] = analyzeMediaSeriesKey(items[i])
}
return inputs
}
func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
return newMediaSeriesKeyResolverFromInputs(analyzeMediaSeriesKeys(items))
}
func newMediaSeriesKeyResolverFromInputs(inputs []mediaSeriesKeyInputs) mediaSeriesKeyResolver {
resolver := mediaSeriesKeyResolver{
pathCounts: make(map[string]int),
externalCounts: make(map[string]int),
titleCounts: make(map[string]int),
pathTitles: make(map[string]string),
}
for _, item := range items {
if !mediaLooksEpisodicForGrouping(item) {
pathTitleCandidates := make(map[string]map[string]struct{})
for _, input := range inputs {
if !input.episodic {
continue
}
if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") {
resolver.pathCounts[key]++
if strings.HasPrefix(input.pathKey, "library-path") {
resolver.pathCounts[input.pathKey]++
if input.titleKey != "" {
if pathTitleCandidates[input.pathKey] == nil {
pathTitleCandidates[input.pathKey] = make(map[string]struct{})
}
pathTitleCandidates[input.pathKey][input.titleKey] = struct{}{}
}
}
if key := repeatedSeriesExternalKey(item); key != "" {
resolver.externalCounts[key]++
if input.externalKey != "" {
resolver.externalCounts[input.externalKey]++
}
if key := repeatedSeriesTitleKey(item); key != "" {
resolver.titleCounts[key]++
if input.titleKey != "" {
resolver.titleCounts[input.titleKey]++
}
}
// A scraper can normalize the same show to one title while the source
// release folders still contain different tags (1080p/2160p, uploader
// names, etc.). Remember an unambiguous title alias for each path group so
// those folders are bridged instead of rendered as separate collections.
pathTitleCandidates := make(map[string]map[string]struct{})
for _, item := range items {
if !mediaLooksEpisodicForGrouping(item) {
continue
}
pathKey := mediaSeriesRawKey(item)
titleKey := repeatedSeriesTitleKey(item)
if !strings.HasPrefix(pathKey, "library-path") || titleKey == "" || resolver.titleCounts[titleKey] < 2 {
continue
}
if pathTitleCandidates[pathKey] == nil {
pathTitleCandidates[pathKey] = make(map[string]struct{})
}
pathTitleCandidates[pathKey][titleKey] = struct{}{}
}
for pathKey, candidates := range pathTitleCandidates {
if len(candidates) != 1 {
continue
}
repeatedTitle := ""
repeatedCount := 0
for titleKey := range candidates {
resolver.pathTitles[pathKey] = titleKey
if resolver.titleCounts[titleKey] < 2 {
continue
}
repeatedTitle = titleKey
repeatedCount++
}
if repeatedCount == 1 {
resolver.pathTitles[pathKey] = repeatedTitle
}
}
return resolver
}
// resolveMediaSeriesKeys 对输入只做一轮派生字段计算,返回可供后续 O(1) 解析的
// resolver 以及每条记录对应的 key。这是整库剧集加载的快速路径。
func resolveMediaSeriesKeys(items []model.Media) (mediaSeriesKeyResolver, []string) {
inputs := analyzeMediaSeriesKeys(items)
resolver := newMediaSeriesKeyResolverFromInputs(inputs)
keys := make([]string, len(items))
for i := range items {
keys[i] = resolver.keyFromInputs(items[i], inputs[i])
}
return resolver, keys
}
func (r mediaSeriesKeyResolver) key(media model.Media) string {
if mediaLooksEpisodicForGrouping(media) {
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
return r.keyFromInputs(media, analyzeMediaSeriesKey(media))
}
func (r mediaSeriesKeyResolver) keyFromInputs(media model.Media, input mediaSeriesKeyInputs) string {
if input.episodic {
if strings.HasPrefix(input.pathKey, "library-path") {
if titleKey := r.pathTitles[input.pathKey]; titleKey != "" {
return compactSeriesKey(titleKey)
}
// A series directory is the strongest identity for mixed rows:
// main episodes and specials (CM/NCOP/PV/OVA) may be scraped to
// slightly different titles, but they still belong to one show.
if r.pathCounts[pathKey] > 1 {
return compactSeriesKey(pathKey)
if r.pathCounts[input.pathKey] > 1 {
return compactSeriesKey(input.pathKey)
}
}
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
return compactSeriesKey(key)
if input.titleKey != "" && r.titleCounts[input.titleKey] > 1 {
return compactSeriesKey(input.titleKey)
}
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
return compactSeriesKey(key)
if input.externalKey != "" && r.externalCounts[input.externalKey] > 1 {
return compactSeriesKey(input.externalKey)
}
}
return mediaSeriesKey(media)
+127
View File
@@ -593,3 +593,130 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
func TestListMediaEpisodesMovieTypeSkipsFullLibraryIndex(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movie-fast"}, Name: "电影", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
movie := model.Media{
Base: model.Base{ID: "movie-fast"},
LibraryID: lib.ID,
Title: "电影",
Path: "/media/movies/电影/电影.mkv",
}
if err := repos.DB.Create(&movie).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), movie.ID, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != movie.ID {
t.Fatalf("ListMediaEpisodes got %#v, want the movie itself", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("movie detail should not build a full-library episode index")
}
}
func TestListMediaEpisodesUsesSeriesIDFastPath(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-series-fast"}, Name: "剧集", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
Base: model.Base{ID: "episode-2"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "episode-1"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "other-series"},
LibraryID: lib.ID,
SeriesID: "other",
Title: "其他剧",
Path: "/media/tv/其他剧/Season 1/其他剧.S01E01.mkv",
},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), "episode-1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 2 || eps[0].ID != "episode-1" || eps[1].ID != "episode-2" {
t.Fatalf("SeriesID fast path got %#v, want both episodes in order", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("SeriesID detail fast path should not build a full-library episode index")
}
}
func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "动画", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1},
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
visibility := MediaVisibility{IncludeNSFW: true}
cards, total, err := svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
t.Fatalf("cold series cards = %#v, total = %d; want one two-episode card", cards, total)
}
cachedObj, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, visibility))
if !ok {
t.Fatal("expected full library rows and precomputed cards to be cached")
}
cached, ok := cachedObj.(*libraryRowsCacheValue)
if !ok || cached.Cards == nil || len(cached.Cards) != 1 {
t.Fatalf("cached rows value = %#v, want one precomputed card", cachedObj)
}
cards, total, err = svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
t.Fatalf("warm series cards = %#v, total = %d; want cached result", cards, total)
}
}
+256
View File
@@ -0,0 +1,256 @@
package service
import (
"sort"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSortSpec is the normalized sort request shared by media and series endpoints.
type MediaSortSpec struct {
Field string
Order string
}
// NormalizeMediaSort validates the request against the same fields the web UI
// exposes. Unknown values fall back to the historical release-date ordering.
func NormalizeMediaSort(field, order string) MediaSortSpec {
field = strings.ToLower(strings.TrimSpace(field))
switch field {
case "title", "release_date", "year", "created_at", "updated_at", "rating", "duration", "bitrate", "last_played", "random":
case "imdb_rating":
field = "rating"
default:
field = "release_date"
}
order = strings.ToLower(strings.TrimSpace(order))
if order != "asc" && order != "desc" {
if field == "title" {
order = "asc"
} else {
order = "desc"
}
}
if field == "random" {
// The web client shuffles the complete result so pagination stays stable.
field = "release_date"
order = "desc"
}
return MediaSortSpec{Field: field, Order: order}
}
// SortMediaItems returns a sorted copy of the grouped media list. The input is
// often backed by an immutable runtime cache, so callers must not rely on the
// returned slice aliasing it.
func SortMediaItems(items []MediaItem, field, order string, history map[string]time.Time) []MediaItem {
spec := NormalizeMediaSort(field, order)
out := append([]MediaItem(nil), items...)
if len(out) < 2 {
return out
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareMediaByField(out[i], out[j], spec, history)
if cmp == 0 {
cmp = compareID(out[i].ID, out[j].ID)
}
return cmp < 0
})
return out
}
// SortSeriesCards returns a sorted copy of series cards.
func SortSeriesCards(cards []SeriesCard, field, order string, history map[string]time.Time) []SeriesCard {
spec := NormalizeMediaSort(field, order)
out := append([]SeriesCard(nil), cards...)
if len(out) < 2 {
return out
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareSeriesByField(out[i], out[j], spec, history)
if cmp == 0 {
cmp = compareID(out[i].Key, out[j].Key)
}
return cmp < 0
})
return out
}
func compareMediaByField(a, b MediaItem, spec MediaSortSpec, history map[string]time.Time) int {
switch spec.Field {
case "title":
ta, tb := mediaSortTitle(a.Media), mediaSortTitle(b.Media)
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(ta)), strings.ToLower(strings.TrimSpace(tb))), strings.TrimSpace(ta) != "", strings.TrimSpace(tb) != "", spec.Order)
case "release_date":
ta, oka := mediaReleaseTime(a.Media)
tb, okb := mediaReleaseTime(b.Media)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "year":
return compareOrdered(compareInt(a.Year, b.Year), true, true, spec.Order)
case "created_at":
return compareOrdered(compareTime(a.CreatedAt, b.CreatedAt), !a.CreatedAt.IsZero(), !b.CreatedAt.IsZero(), spec.Order)
case "updated_at":
ta, oka := mediaUpdatedTime(a.Media)
tb, okb := mediaUpdatedTime(b.Media)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "rating":
return compareOrdered(compareFloat(float64(a.Rating), float64(b.Rating)), true, true, spec.Order)
case "duration":
return compareOrdered(compareInt(a.DurationSec, b.DurationSec), true, true, spec.Order)
case "bitrate":
return compareOrdered(compareInt64(a.SizeBytes, b.SizeBytes), true, true, spec.Order)
case "last_played":
ta, oka := mediaItemLastPlayedTime(a, history)
tb, okb := mediaItemLastPlayedTime(b, history)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
default:
return 0
}
}
func compareSeriesByField(a, b SeriesCard, spec MediaSortSpec, history map[string]time.Time) int {
switch spec.Field {
case "title":
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(mediaSortTitle(a.Rep))), strings.ToLower(strings.TrimSpace(mediaSortTitle(b.Rep)))), strings.TrimSpace(mediaSortTitle(a.Rep)) != "", strings.TrimSpace(mediaSortTitle(b.Rep)) != "", spec.Order)
case "release_date":
ta, oka := mediaReleaseTime(a.Rep)
tb, okb := mediaReleaseTime(b.Rep)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "year":
return compareOrdered(compareInt(a.Rep.Year, b.Rep.Year), true, true, spec.Order)
case "created_at":
return compareOrdered(compareTime(a.Rep.CreatedAt, b.Rep.CreatedAt), !a.Rep.CreatedAt.IsZero(), !b.Rep.CreatedAt.IsZero(), spec.Order)
case "updated_at":
ta, oka := seriesUpdatedTime(a)
tb, okb := seriesUpdatedTime(b)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "rating":
return compareOrdered(compareFloat(float64(a.Rep.Rating), float64(b.Rep.Rating)), true, true, spec.Order)
case "duration":
return compareOrdered(compareInt(a.Rep.DurationSec, b.Rep.DurationSec), true, true, spec.Order)
case "bitrate":
return compareOrdered(compareInt64(a.Rep.SizeBytes, b.Rep.SizeBytes), true, true, spec.Order)
case "last_played":
ta, oka := seriesLastPlayed(a, history)
tb, okb := seriesLastPlayed(b, history)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
default:
return 0
}
}
func mediaSortTitle(m model.Media) string {
if strings.TrimSpace(m.Title) != "" {
return m.Title
}
return m.OriginalName
}
func mediaReleaseTime(m model.Media) (time.Time, bool) {
if raw := strings.TrimSpace(m.ReleaseDate); raw != "" {
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
return parsed, true
}
}
if m.Year > 0 {
return time.Date(m.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true
}
return time.Time{}, false
}
func mediaUpdatedTime(m model.Media) (time.Time, bool) {
if !m.UpdatedAt.IsZero() {
return m.UpdatedAt, true
}
if !m.CreatedAt.IsZero() {
return m.CreatedAt, true
}
return time.Time{}, false
}
func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
return *card.LastAddedAt, true
}
return mediaUpdatedTime(card.Rep)
}
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
for _, id := range []string{card.Rep.ID, card.LinkMedia.ID} {
if played, ok := history[id]; ok {
return played, true
}
}
return time.Time{}, false
}
func mediaItemLastPlayedTime(item MediaItem, history map[string]time.Time) (time.Time, bool) {
best, found := history[item.ID]
for _, version := range item.Versions {
if played, ok := history[version.ID]; ok && (!found || played.After(best)) {
best = played
found = true
}
}
return best, found
}
func compareOrdered(cmp int, aValid, bValid bool, order string) int {
if !aValid && !bValid {
return 0
}
if !aValid {
return 1
}
if !bValid {
return -1
}
if order == "desc" {
return -cmp
}
return cmp
}
func compareTime(a, b time.Time) int {
if a.Before(b) {
return -1
}
if a.After(b) {
return 1
}
return 0
}
func compareInt(a, b int) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareInt64(a, b int64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareFloat(a, b float64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareID(a, b string) int {
return strings.Compare(a, b)
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestSortMediaItemsTitleAscEmptyLast(t *testing.T) {
items := []MediaItem{
{Media: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
{Media: model.Media{Base: model.Base{ID: "empty"}}},
{Media: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
}
got := SortMediaItems(items, "title", "asc", nil)
if got[0].ID != "a" || got[1].ID != "b" || got[2].ID != "empty" {
t.Fatalf("title order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
}
}
func TestSortMediaItemsReleaseDescEmptyLast(t *testing.T) {
items := []MediaItem{
{Media: model.Media{Base: model.Base{ID: "old"}, ReleaseDate: "2020-01-01"}},
{Media: model.Media{Base: model.Base{ID: "empty"}}},
{Media: model.Media{Base: model.Base{ID: "new"}, ReleaseDate: "2024-01-01"}},
}
got := SortMediaItems(items, "release_date", "desc", nil)
if got[0].ID != "new" || got[1].ID != "old" || got[2].ID != "empty" {
t.Fatalf("release order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
}
}
func TestSortSeriesCardsTitleAsc(t *testing.T) {
cards := []SeriesCard{
{Key: "b", Rep: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
{Key: "a", Rep: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
}
got := SortSeriesCards(cards, "title", "asc", nil)
if got[0].Key != "a" || got[1].Key != "b" {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
+13 -1
View File
@@ -281,7 +281,8 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
@@ -295,6 +296,17 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
if page[0].Media.Path != rows[0].Path {
t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path)
}
cacheKey := svc.groupedItemsCacheKey(lib.ID, []string{lib.ID}, repository.MediaQueryFilter{IncludeNSFW: true})
if _, ok := svc.cache.GetObject(cacheKey); !ok {
t.Fatal("expected grouped media items to be cached after the first page request")
}
secondPage, secondTotal, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 2, 1, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if secondTotal != total || len(secondPage) != 1 || secondPage[0].Media.Path != rows[2].Path {
t.Fatalf("cached second page = %#v, total = %d; want Matrix item and total %d", secondPage, secondTotal, total)
}
}
func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) {
+68 -13
View File
@@ -41,24 +41,62 @@ func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackServ
// ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A
// position within 30 seconds of the duration auto-flags the item as
// completed so the home page can hide it from "Continue Watching".
// ProgressUpdate is a playback-progress report. SessionID, SessionStartedAtMs
// and Sequence are optional for compatibility with legacy callers.
type ProgressUpdate struct {
UserID string
MediaID string
PositionMs int64
DurationMs int64
SessionID string
SessionStartedAtMs int64
Sequence int64
}
// RecordProgress records an unversioned progress report. It is retained for
// older callers that do not provide playback-session metadata.
func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error {
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
completed := dur > 0 && position >= dur-30_000
h := &model.PlaybackHistory{
return p.RecordProgressUpdate(ctx, ProgressUpdate{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
DurationMs: duration,
})
}
// RecordProgressUpdate upserts the resume position for a (user, media) pair.
// When session metadata is present, stale reports are ignored so a delayed
// request cannot overwrite a newer position or completion state.
func (p *PlaybackService) RecordProgressUpdate(ctx context.Context, update ProgressUpdate) error {
if update.UserID == "" || update.MediaID == "" {
return errors.New("missing user or media")
}
return p.repo.History.Upsert(ctx, h)
dur := p.resolvePlaybackDuration(ctx, update.UserID, update.MediaID, update.DurationMs)
h := &model.PlaybackHistory{
UserID: update.UserID,
MediaID: update.MediaID,
PositionMs: update.PositionMs,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(update.PositionMs, dur),
SessionID: update.SessionID,
SessionStartedAtMs: update.SessionStartedAtMs,
Sequence: update.Sequence,
}
return p.repo.History.UpsertProgress(ctx, h)
}
// playbackProgressCompleted deliberately requires a positive position. For
// very short clips the 90% threshold avoids marking a zero-second sample as
// finished; otherwise the last 30 seconds are treated as completed.
func playbackProgressCompleted(position, duration int64) bool {
if position <= 0 || duration <= 0 {
return false
}
if duration <= 30_000 {
return position*10 >= duration*9
}
return position >= duration-30_000
}
// GetProgress returns the saved resume row for one media item, or nil when absent.
@@ -227,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
return out, nil
}
// ListFavouriteIDs returns the user's favourite media IDs without hydrating
// remote Emby items. Heart-icon state only needs the IDs; fetching each remote
// detail on every library open adds a few hundred milliseconds.
func (p *PlaybackService) ListFavouriteIDs(ctx context.Context, userID string) ([]string, error) {
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(favs))
for _, fav := range favs {
if fav.MediaID != "" {
ids = append(ids, fav.MediaID)
}
}
return ids, nil
}
// ─── Playlists ──────────────────────────────────────────────────────────────
// CreatePlaylist persists a new playlist owned by userID.
+22
View File
@@ -91,3 +91,25 @@ func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
}
}
func TestPlaybackProgressCompletedThreshold(t *testing.T) {
tests := []struct {
name string
position int64
duration int64
want bool
}{
{name: "zero duration", position: 10_000, duration: 0, want: false},
{name: "short clip early", position: 10_000, duration: 20_000, want: false},
{name: "short clip near end", position: 18_000, duration: 20_000, want: true},
{name: "movie before final window", position: 80_000, duration: 120_000, want: false},
{name: "movie in final window", position: 95_000, duration: 120_000, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := playbackProgressCompleted(tt.position, tt.duration); got != tt.want {
t.Fatalf("playbackProgressCompleted(%d, %d) = %t, want %t", tt.position, tt.duration, got, tt.want)
}
})
}
}
+415 -51
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"reflect"
"strings"
"sync"
"time"
@@ -13,20 +14,36 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
const (
defaultRuntimeCacheEntryLimit = 2048
runtimeCacheEntryOverheadBytes = 96
runtimeCacheMapEntryOverheadBytes = 64
runtimeCacheStringHeaderBytes = 16
runtimeCacheEstimateMaxDepth = 64
)
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
obj map[string]runtimeObjectItem
limit int
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
maxBytes int64
bytesUsed int64
}
type runtimeCacheItem struct {
raw []byte
expiresAt time.Time
raw []byte
expiresAt time.Time
staleUntil time.Time
lastUsed time.Time
size int64
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
@@ -37,10 +54,22 @@ type runtimeCacheItem struct {
type runtimeObjectItem struct {
value any
expiresAt time.Time
lastUsed time.Time
size int64
}
func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheService {
c := &RuntimeCacheService{log: log, memory: map[string]runtimeCacheItem{}, obj: map[string]runtimeObjectItem{}, limit: 2048}
maxBytes := int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
if cfg != nil && cfg.Cache.MemoryMaxSizeMB > 0 {
maxBytes = int64(cfg.Cache.MemoryMaxSizeMB) * 1024 * 1024
}
c := &RuntimeCacheService{
log: log,
memory: map[string]runtimeCacheItem{},
obj: map[string]runtimeObjectItem{},
limit: defaultRuntimeCacheEntryLimit,
maxBytes: maxBytes,
}
if cfg == nil {
return c
}
@@ -70,6 +99,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))
}
@@ -95,19 +127,49 @@ 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
}
c.setMemory(fullKey, raw, 2*time.Second)
c.setMemoryOwned(fullKey, raw, 2*time.Second)
return true
}
}
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
@@ -117,12 +179,33 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
return
}
fullKey := c.key(key)
c.setMemory(fullKey, raw, ttl)
c.setMemoryOwned(fullKey, raw, ttl)
if c.client != nil {
_ = c.client.Set(ctx, fullKey, raw, ttl).Err()
}
}
// 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) == "" {
@@ -130,41 +213,70 @@ func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
}
fullKey := c.key(key)
now := time.Now()
c.mu.RLock()
c.mu.Lock()
item, ok := c.obj[fullKey]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.obj, fullKey)
c.mu.Unlock()
return nil, false
}
if !now.Before(item.expiresAt) {
c.removeObjectLocked(fullKey)
c.mu.Unlock()
return nil, false
}
item.lastUsed = now
c.obj[fullKey] = item
c.mu.Unlock()
return item.value, true
}
// SetObject 存入一个此后视为不可变的对象。
// SetObject 存入一个此后视为不可变的对象。对象会按估算内存计入进程内缓存
// 总预算;单个对象超过预算时不会进入缓存,避免一次大列表请求再次打爆内存。
func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
}
fullKey := c.key(key)
size := estimateRuntimeCacheObjectSize(fullKey, value)
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.obj) >= c.limit {
for k, item := range c.obj {
if now.After(item.expiresAt) || len(c.obj) >= c.limit {
delete(c.obj, k)
}
if len(c.obj) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeObjectLocked(fullKey)
if !c.makeRoomLocked(now, size) {
return
}
c.obj[fullKey] = runtimeObjectItem{
value: value,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
// SetMaxSizeMB 热更新进程内缓存总预算。降低上限时会立即淘汰最久未使用的
// 条目;传 0 或负数时恢复默认预算。
func (c *RuntimeCacheService) SetMaxSizeMB(maxMB int) {
if c == nil {
return
}
if maxMB <= 0 {
maxMB = config.DefaultCacheMemoryMaxSizeMB
}
maxBytes := int64(maxMB) * 1024 * 1024
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.maxBytes = maxBytes
c.evictExpiredLocked(now)
for c.bytesUsed > c.maxBytes {
if !c.evictOldestLocked() {
break
}
}
c.obj[fullKey] = runtimeObjectItem{value: value, expiresAt: now.Add(ttl)}
}
func (c *RuntimeCacheService) DeletePrefix(ctx context.Context, prefix string) {
@@ -201,40 +313,167 @@ 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.RLock()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.memory[key]
c.mu.RUnlock()
if !ok {
return nil, false
return nil, false, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.memory, key)
c.mu.Unlock()
return nil, false
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
return item.raw, true
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
return nil, false, false
}
item.lastUsed = now
c.memory[key] = item
return item.raw, true, !now.Before(item.expiresAt)
}
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
if ttl <= 0 || len(raw) == 0 {
c.setMemoryBytes(key, raw, ttl, false)
}
func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Duration) {
c.setMemoryBytes(key, raw, ttl, true)
}
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
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()
defer c.mu.Unlock()
if len(c.memory) >= c.limit {
now := time.Now()
for k, item := range c.memory {
if now.After(item.expiresAt) || len(c.memory) >= c.limit {
delete(c.memory, k)
}
if len(c.memory) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeMemoryLocked(key)
if !c.makeRoomLocked(now, size) {
return
}
if !owned {
raw = append([]byte(nil), raw...)
}
c.memory[key] = runtimeCacheItem{
raw: raw,
expiresAt: now.Add(freshTTL),
staleUntil: now.Add(staleTTL),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
func (c *RuntimeCacheService) makeRoomLocked(now time.Time, incoming int64) bool {
if incoming <= 0 {
incoming = 1
}
if c.limit <= 0 {
c.limit = defaultRuntimeCacheEntryLimit
}
if c.maxBytes <= 0 {
c.maxBytes = int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
}
if incoming > c.maxBytes {
return false
}
c.evictExpiredLocked(now)
for c.entryCountLocked() >= c.limit || c.bytesUsed+incoming > c.maxBytes {
if !c.evictOldestLocked() {
return false
}
}
c.memory[key] = runtimeCacheItem{raw: append([]byte(nil), raw...), expiresAt: time.Now().Add(ttl)}
return true
}
func (c *RuntimeCacheService) entryCountLocked() int {
return len(c.memory) + len(c.obj)
}
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
for key, item := range c.memory {
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
}
}
for key, item := range c.obj {
if !now.Before(item.expiresAt) {
c.removeObjectLocked(key)
}
}
}
func (c *RuntimeCacheService) evictOldestLocked() bool {
var (
oldestKey string
oldestKind byte
oldestAt time.Time
)
for key, item := range c.memory {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'm', item.lastUsed
}
}
for key, item := range c.obj {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'o', item.lastUsed
}
}
switch oldestKind {
case 'm':
c.removeMemoryLocked(oldestKey)
return true
case 'o':
c.removeObjectLocked(oldestKey)
return true
default:
return false
}
}
func (c *RuntimeCacheService) removeMemoryLocked(key string) {
item, ok := c.memory[key]
if !ok {
return
}
delete(c.memory, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) removeObjectLocked(key string) {
item, ok := c.obj[key]
if !ok {
return
}
delete(c.obj, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
@@ -242,12 +481,137 @@ func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
defer c.mu.Unlock()
for key := range c.memory {
if strings.HasPrefix(key, prefix) {
delete(c.memory, key)
c.removeMemoryLocked(key)
}
}
for key := range c.obj {
if strings.HasPrefix(key, prefix) {
delete(c.obj, key)
c.removeObjectLocked(key)
}
}
}
type runtimeCacheVisit struct {
kind reflect.Kind
ptr uintptr
}
func estimateRuntimeCacheObjectSize(key string, value any) int64 {
seen := make(map[runtimeCacheVisit]struct{})
size := int64(len(key)) + runtimeCacheEntryOverheadBytes
size += estimateRuntimeCacheReflectSize(reflect.ValueOf(value), seen, 0)
return size
}
func estimateRuntimeCacheReflectSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface:
if v.IsNil() {
return 0
}
return estimateRuntimeCacheReflectSize(v.Elem(), seen, depth+1)
case reflect.Pointer:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
return int64(v.Type().Elem().Size()) + estimateRuntimeCacheNestedSize(v.Elem(), seen, depth+1)
case reflect.String:
return int64(len(v.String())) + runtimeCacheStringHeaderBytes
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Cap())*int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Len())*runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(v.Type().Size())
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(v.Type().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return int64(v.Type().Size())
}
}
func estimateRuntimeCacheNestedSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
return estimateRuntimeCacheReflectSize(v, seen, depth+1)
case reflect.String:
return int64(len(v.String()))
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Cap()) * int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Len()) * runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(0)
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(0)
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return 0
}
}
func markRuntimeCacheVisit(v reflect.Value, seen map[runtimeCacheVisit]struct{}) bool {
ptr := v.Pointer()
if ptr == 0 {
return false
}
visit := runtimeCacheVisit{kind: v.Kind(), ptr: ptr}
if _, ok := seen[visit]; ok {
return true
}
seen[visit] = struct{}{}
return false
}
+189
View File
@@ -0,0 +1,189 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newRuntimeCacheForTest(t *testing.T, maxMB int) *RuntimeCacheService {
t.Helper()
cfg := &config.Config{}
cfg.Cache.MemoryMaxSizeMB = maxMB
return NewRuntimeCacheService(cfg, zap.NewNop())
}
func TestRuntimeCacheEvictsByByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("large", strings.Repeat("a", 800<<10), time.Minute)
cache.SetObject("small", strings.Repeat("b", 300<<10), time.Minute)
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("cache bytes=%d exceeds max=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("large"); ok {
t.Fatal("expected oldest large entry to be evicted")
}
if _, ok := cache.GetObject("small"); !ok {
t.Fatal("expected newest small entry to remain cached")
}
}
func TestRuntimeCacheSkipsOversizedObject(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("huge", strings.Repeat("x", 2<<20), time.Minute)
if _, ok := cache.GetObject("huge"); ok {
t.Fatal("oversized object must not be cached")
}
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d, want 0", cache.bytesUsed)
}
}
func TestRuntimeCachePrefixDeleteReleasesByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
for i := 0; i < 4; i++ {
cache.SetJSON(context.Background(), fmt.Sprintf("media:%d", i), strings.Repeat("x", 64<<10), time.Minute)
}
if cache.bytesUsed == 0 {
t.Fatal("expected cached bytes")
}
cache.DeletePrefix(context.Background(), "media:")
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d after prefix delete, want 0", cache.bytesUsed)
}
for i := 0; i < 4; i++ {
var out string
if cache.GetJSON(context.Background(), fmt.Sprintf("media:%d", i), &out) {
t.Fatalf("entry media:%d should have been deleted", i)
}
}
}
func TestRuntimeCacheStillEnforcesEntryLimit(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.limit = 3
for i := 0; i < 5; i++ {
cache.SetObject(fmt.Sprintf("item-%d", i), i, time.Minute)
}
if got := cache.entryCountLocked(); got != 3 {
t.Fatalf("entry count=%d, want 3", got)
}
}
func TestRuntimeCacheEstimatesMediaRows(t *testing.T) {
rows := []model.Media{{
Title: strings.Repeat("t", 1024),
Path: "/media/movies/example.mkv",
PosterURL: "https://image.example/poster.jpg",
BackdropURL: "https://image.example/backdrop.jpg",
Overview: strings.Repeat("o", 2048),
Genres: "Action,Adventure",
}}
size := estimateRuntimeCacheObjectSize("media:test", rows)
if size < int64(len(rows[0].Title)+len(rows[0].Overview)) {
t.Fatalf("estimated size %d is smaller than payload", size)
}
}
func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
cache.SetObject("old", strings.Repeat("a", 700<<10), time.Minute)
cache.SetObject("new", strings.Repeat("b", 700<<10), time.Minute)
if cache.bytesUsed <= 1<<20 {
t.Fatalf("test setup bytesUsed=%d, want >1MiB", cache.bytesUsed)
}
cache.SetMaxSizeMB(1)
if cache.maxBytes != 1<<20 {
t.Fatalf("maxBytes=%d, want 1MiB", cache.maxBytes)
}
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("bytesUsed=%d exceeds maxBytes=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("old"); ok {
t.Fatal("oldest entry should be evicted after lowering cache limit")
}
if _, ok := cache.GetObject("new"); !ok {
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)
}
}
+16
View File
@@ -119,6 +119,22 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
}
cfg.Cache.ImagesMaxSizeMB = n
}
case "cache.memory_max_size_mb":
n := config.DefaultCacheMemoryMaxSizeMB
if value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return
}
n = parsed
}
if n <= 0 {
n = config.DefaultCacheMemoryMaxSizeMB
}
if n > 4096 {
n = 4096
}
cfg.Cache.MemoryMaxSizeMB = n
}
}
+19
View File
@@ -41,3 +41,22 @@ func TestApplyRuntimeSettingTranscodeSwitches(t *testing.T) {
t.Fatalf("max cpu threads = %d, want clamp 1", cfg.App.MaxCPUThreads)
}
}
func TestApplyRuntimeSettingMemoryCacheLimit(t *testing.T) {
cfg := &config.Config{}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "64")
if cfg.Cache.MemoryMaxSizeMB != 64 {
t.Fatalf("memory cache limit = %d, want 64", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "")
if cfg.Cache.MemoryMaxSizeMB != config.DefaultCacheMemoryMaxSizeMB {
t.Fatalf("empty value should restore default, got %d", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "99999")
if cfg.Cache.MemoryMaxSizeMB != 4096 {
t.Fatalf("memory cache limit should clamp to 4096, got %d", cfg.Cache.MemoryMaxSizeMB)
}
}
+81 -39
View File
@@ -8,53 +8,95 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// existingLocalMediaSnapshot 保留整库快照入口,主要供兼容性调用和测试使用。
// 实际扫库路径使用 existingLocalMediaSnapshotForRoot,避免多根目录库重复持有
// 与当前 root 无关的媒体记录。
func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) {
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
return s.existingLocalMediaSnapshotForRoot(ctx, libraryID, "", "")
}
// existingLocalMediaSnapshotForRoot 以流式方式读取当前 root 的已有媒体索引,
// 不再先构造 []model.Media 再复制成 map。map 中的字符串只在最终索引中保留一份,
// 可以显著降低大库扫描时的峰值内存。
func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context, libraryID, rootID, rootPath string) (map[string]existingLocalMedia, error) {
if s == nil || s.repo == nil || s.repo.DB == nil {
return map[string]existingLocalMedia{}, nil
}
libraryID = strings.TrimSpace(libraryID)
rootID = strings.TrimSpace(rootID)
rootPath = strings.TrimSpace(rootPath)
query := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%")
if rootID != "" {
// 兼容尚未回填 library_root_id 的旧数据;没有 root id 的行再由路径归属过滤。
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
}
rows, err := query.
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
Find(&rows).Error; err != nil {
Rows()
if err != nil {
return nil, err
}
snapshot := make(map[string]existingLocalMedia, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Path) == "" {
defer rows.Close()
snapshot := make(map[string]existingLocalMedia)
for rows.Next() {
var (
path string
row existingLocalMedia
)
if err := rows.Scan(
&path,
&row.LibraryRootID,
&row.RelativePath,
&row.Title,
&row.OriginalName,
&row.EpisodeTitle,
&row.SizeBytes,
&row.DurationSec,
&row.Width,
&row.Height,
&row.VideoCodec,
&row.AudioCodec,
&row.Container,
&row.STRMURL,
&row.FileID,
&row.PosterURL,
&row.BackdropURL,
&row.Overview,
&row.Year,
&row.ReleaseDate,
&row.Rating,
&row.TMDbID,
&row.BangumiID,
&row.DoubanID,
&row.TheTVDBID,
&row.SeasonNum,
&row.EpisodeNum,
&row.Genres,
&row.Countries,
&row.Languages,
&row.NSFW,
&row.ScrapeStatus,
); err != nil {
return nil, err
}
path = strings.TrimSpace(path)
if path == "" {
continue
}
snapshot[filepath.Clean(row.Path)] = existingLocalMedia{
LibraryRootID: row.LibraryRootID,
RelativePath: row.RelativePath,
Title: row.Title,
OriginalName: row.OriginalName,
EpisodeTitle: row.EpisodeTitle,
SizeBytes: row.SizeBytes,
DurationSec: row.DurationSec,
Width: row.Width,
Height: row.Height,
VideoCodec: row.VideoCodec,
AudioCodec: row.AudioCodec,
Container: row.Container,
STRMURL: row.STRMURL,
FileID: row.FileID,
PosterURL: row.PosterURL,
BackdropURL: row.BackdropURL,
Overview: row.Overview,
Year: row.Year,
ReleaseDate: row.ReleaseDate,
Rating: row.Rating,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
DoubanID: row.DoubanID,
TheTVDBID: row.TheTVDBID,
SeasonNum: row.SeasonNum,
EpisodeNum: row.EpisodeNum,
Genres: row.Genres,
Countries: row.Countries,
Languages: row.Languages,
NSFW: row.NSFW,
ScrapeStatus: row.ScrapeStatus,
if rootPath != "" {
if rootID == "" || strings.TrimSpace(row.LibraryRootID) == "" {
if !pathBelongsToRoot(path, rootPath) {
continue
}
}
}
snapshot[filepath.Clean(path)] = row
}
if err := rows.Err(); err != nil {
return nil, err
}
return snapshot, nil
}
@@ -59,3 +59,34 @@ func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) {
t.Fatalf("identity fields not preserved: %#v", row)
}
}
func TestExistingLocalMediaSnapshotForRootFiltersOtherRoots(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
root1 := t.TempDir()
root2 := t.TempDir()
path1 := filepath.Join(root1, "Root1.mkv")
path2 := filepath.Join(root2, "Root2.mkv")
if err := db.Create(&[]model.Media{
{LibraryID: "lib-1", LibraryRootID: "root-1", Path: path1, SizeBytes: 11},
{LibraryID: "lib-1", LibraryRootID: "root-2", Path: path2, SizeBytes: 22},
}).Error; err != nil {
t.Fatal(err)
}
got, err := scanner.existingLocalMediaSnapshotForRoot(t.Context(), "lib-1", "root-1", root1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got)
}
if _, ok := got[filepath.Clean(path1)]; !ok {
t.Fatalf("root-1 media missing from %#v", got)
}
if _, ok := got[filepath.Clean(path2)]; ok {
t.Fatalf("root-2 media leaked into root-1 snapshot: %#v", got)
}
}
@@ -268,6 +268,74 @@ func TestScanLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) {
}
}
func TestScanLibraryPrefersEpisodeArtworkOverSeriesFanart(t *testing.T) {
for _, tc := range []struct {
name string
episodeArt string
}{
{name: "thumb suffix", episodeArt: "Show - S01E01-thumb.jpg"},
{name: "video stem", episodeArt: "Show - S01E01.jpg"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "Show", "Season 01")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "Show - S01E01.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(
`<episodedetails><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`,
), 0o644); err != nil {
t.Fatal(err)
}
episodeArt := filepath.Join(seasonDir, tc.episodeArt)
if err := os.WriteFile(episodeArt, []byte("episode still"), 0o644); err != nil {
t.Fatal(err)
}
seriesFanart := filepath.Join(root, "Show", "fanart.jpg")
if err := os.WriteFile(seriesFanart, []byte("series backdrop"), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
existing := model.Media{
LibraryID: lib.ID,
Title: "Show",
Path: mediaPath,
SizeBytes: 1,
SeasonNum: 1,
EpisodeNum: 1,
BackdropURL: "https://image.example/episode-still.jpg",
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&existing).Error; err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "id = ?", existing.ID).Error; err != nil {
t.Fatal(err)
}
if media.BackdropURL != episodeArt {
t.Fatalf("backdrop_url = %q, want episode artwork %q (series fanart %q must not win)", media.BackdropURL, episodeArt, seriesFanart)
}
})
}
}
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
+15 -7
View File
@@ -73,11 +73,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
res := &ScanResult{LibraryID: lib.ID}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
existingMedia = nil
}
roots, err := s.localLibraryScanRoots(ctx, lib)
if err != nil {
@@ -109,6 +104,15 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
continue
}
existingMedia, snapshotErr := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if snapshotErr != nil {
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(snapshotErr))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, &root, existingMedia, writeBatch, res)
if walkErr != nil {
addScanError(res, root.Path, walkErr)
@@ -147,9 +151,13 @@ func (s *ScannerService) scanLocalLibraryRoot(ctx context.Context, lib *model.Li
return res, err
}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
existingMedia, err := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(err))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, root, existingMedia, writeBatch, res)
+11 -9
View File
@@ -40,9 +40,9 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("source", originalSource),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.String("source", redactSensitiveURL(originalSource)),
zap.Error(redactSensitiveError(err)))
if current != "" && isHTTPish(current) {
return originalSource, current
}
@@ -53,16 +53,16 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("existing", current),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.String("existing", redactSensitiveURL(current)),
zap.Error(redactSensitiveError(err)))
return current, ""
}
s.log.Warn("scrape artwork prefetch failed; keeping new artwork URL for retry",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.Error(redactSensitiveError(err)))
return candidate, ""
}
if current != "" && isHTTPish(current) {
@@ -98,7 +98,9 @@ func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
}
seen[raw] = struct{}{}
if err := s.images.RemoveCached(raw); err != nil {
s.log.Debug("remove old scraped artwork cache failed", zap.String("url", raw), zap.Error(err))
s.log.Debug("remove old scraped artwork cache failed",
zap.String("url", redactSensitiveURL(raw)),
zap.Error(redactSensitiveError(err)))
}
}
}
@@ -117,8 +117,8 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
if err != nil || len(data) == 0 {
s.log.Warn("scrape artwork download failed",
zap.String("name", name),
zap.String("url", raw),
zap.Error(err))
zap.String("url", redactSensitiveURL(raw)),
zap.Error(redactSensitiveError(err)))
return ""
}
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
+3 -1
View File
@@ -88,7 +88,9 @@ func (b *serviceContainerBuilder) configureMediaSearchBackend() {
}
b.repos.Media.SetSearchBackend(searchBackend)
if b.log != nil {
b.log.Info("opensearch media search enabled", zap.String("index", b.cfg.Search.Index), zap.String("url", b.cfg.Search.OpenSearchURL))
b.log.Info("opensearch media search enabled",
zap.String("index", b.cfg.Search.Index),
zap.String("url", redactSensitiveURL(b.cfg.Search.OpenSearchURL)))
}
}

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