Compare commits

..

50 Commits

Author SHA1 Message Date
truewhile d56fb53a08 优化界面,优化多版本切换逻辑 2026-09-17 22:33:53 +08:00
truewhile 30ca94c79b 优化问题 2026-09-17 14:17:09 +08:00
truewhile 9c1f9a6e7e bug处理 2026-09-17 11:19:38 +08:00
truewhile ec7be38266 优化vr播放逻辑 2026-09-17 09:22:00 +08:00
truewhile ebf791c436 优化 2026-09-17 01:29:10 +08:00
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
truewhile 57ee482146 优化 2026-09-15 23:13:31 +08:00
truewhile a9b3110b2c 优化 2026-09-15 22:05:34 +08:00
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
truewhile c911a57467 bug处理 2026-09-15 09:58:47 +08:00
truewhile 1dbc3f8fe7 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-15 00:00:54 +08:00
truewhile a563ee11d6 优化,添加滚动记忆 2026-09-15 00:00:35 +08:00
truewhile 215ebf6942 Merge pull request #33 from truewhile/cursor/fix-library-scrape-dialog-position
优化
2026-09-14 22:55:16 +08:00
truewhile e4c0e85eaa 优化 2026-09-14 22:54:09 +08:00
truewhile 60f05129c9 优化 2026-09-14 21:56:47 +08:00
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
truewhile 1ec8303fec bug处理 2026-09-14 21:16:34 +08:00
truewhile b28c0d9247 添加115云端解码功能 2026-09-14 17:57:51 +08:00
truewhile 91c6d9dfd9 处理token刷新失败bug 2026-09-14 16:47:44 +08:00
truewhile 95d8126d65 优化windows包,优化windos可执行文件功能 2026-09-14 15:48:59 +08:00
truewhile 3aa8662c45 优化,日志脱敏 2026-09-14 15:12:42 +08:00
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
truewhile 5ddca9a1f6 支持 STRM 播放端点的 HEAD 请求 2026-09-12 22:31:30 +08:00
truewhile e337d9932b Fix Emby person image routing and search dropdown dismissal 2026-09-12 16:55:53 +08:00
245 changed files with 20006 additions and 3852 deletions
+2
View File
@@ -0,0 +1,2 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+6 -2
View File
@@ -115,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
@@ -196,8 +196,12 @@ jobs:
path: web/dist
- name: Build binary
run: |
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
LDFLAGS="$LDFLAGS -H=windowsgui"
fi
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
go build -trimpath -ldflags="$LDFLAGS" \
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
+3 -3
View File
@@ -48,7 +48,7 @@ jobs:
# before the Go toolchain touches the web package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -77,7 +77,7 @@ jobs:
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -130,4 +130,4 @@ jobs:
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -51,7 +51,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
+1
View File
@@ -0,0 +1 @@
20.19.0
+2 -2
View File
@@ -2,7 +2,7 @@
# =============================================================================
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
@@ -15,7 +15,7 @@
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
WORKDIR /app/web
COPY web/package*.json ./
+14
View File
@@ -199,6 +199,11 @@ environment:
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**日志保留时间太短?**
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
@@ -207,6 +212,7 @@ environment:
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
前端构建要求 Node.js `20.19+` 或 `22.12+`。
```bash
npm --prefix web ci
@@ -219,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
Windows 本地打包:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
---
## 鸣谢
+8
View File
@@ -199,6 +199,14 @@ npm --prefix web run dev
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
Build the Windows executable locally:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
---
## Acknowledgements
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// application owns the long-running MeBox server and all resources created
// during startup. Platform entry points decide how the application is
// controlled: a console signal loop or a Windows notification-area icon.
type application struct {
cfg *config.Config
logger *zap.Logger
embyCompatLogger *zap.Logger
serverManager *serverManager
services *service.Container
closeMu sync.Mutex
closeFuncs []func()
shutdown sync.Once
shutdownErr error
}
func newApplication() (*application, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config load failed: %w", err)
}
logger, closeLogger, err := newLoggerWithCloser(cfg)
if err != nil {
return nil, fmt.Errorf("logger init failed: %w", err)
}
app := &application{
cfg: cfg,
logger: logger,
closeFuncs: []func(){closeLogger},
}
if err := app.start(); err != nil {
app.closeLoggers()
return nil, err
}
return app, nil
}
func (a *application) start() error {
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
if err != nil {
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
}
a.embyCompatLogger = embyCompatLogger
a.addCloser(closeEmbyCompatLogger)
appVersion := effectiveVersion(version)
a.logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", a.cfg.App.Port),
zap.String("data_dir", a.cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
)
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
return fmt.Errorf("create dir %s failed: %w", dir, err)
}
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
return fmt.Errorf("database open failed: %w", err)
}
if err := waitForDatabase(db, a.logger); err != nil {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
applyCPUThreadLimit(a.cfg, a.logger)
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
a.serverManager = newServerManager(a.cfg, a.logger, router)
a.services.ReloadHTTPServer = a.serverManager.Reload
if err := a.serverManager.Start(); err != nil {
a.logger.Error("listen failed", zap.Error(err))
return fmt.Errorf("listen failed: %w", err)
}
go func() {
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
a.logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
)
}
}()
helper.Go(a.logger, "services.boot", a.services.Boot)
return nil
}
// Shutdown is idempotent and safe to call from both the tray handler and the
// systray exit callback.
func (a *application) Shutdown() error {
a.shutdown.Do(func() {
a.logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if a.serverManager != nil {
if err := a.serverManager.Shutdown(ctx); err != nil {
a.shutdownErr = err
a.logger.Error("graceful shutdown failed", zap.Error(err))
}
}
cancel()
if a.services != nil {
a.services.Close()
}
a.logger.Info("MeBox stopped")
_ = a.logger.Sync()
if a.embyCompatLogger != nil {
_ = a.embyCompatLogger.Sync()
}
a.closeLoggers()
})
return a.shutdownErr
}
func (a *application) addCloser(fn func()) {
if fn == nil {
return
}
a.closeMu.Lock()
a.closeFuncs = append(a.closeFuncs, fn)
a.closeMu.Unlock()
}
func (a *application) closeLoggers() {
a.closeMu.Lock()
closers := append([]func(){}, a.closeFuncs...)
a.closeFuncs = nil
a.closeMu.Unlock()
for i := len(closers) - 1; i >= 0; i-- {
closers[i]()
}
}
func (a *application) localURL() string {
config.RuntimeMu.RLock()
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
port := a.cfg.App.Port
config.RuntimeMu.RUnlock()
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
}
+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)
}
}
+148
View File
@@ -0,0 +1,148 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
quality, _ := strconv.Atoi(c.Query("quality"))
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
if err != nil {
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
var req struct {
Definition int `json:"definition"`
}
_ = c.ShouldBindJSON(&req)
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
definition, _ := strconv.Atoi(c.Query("definition"))
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
if err == nil {
return
}
if c.Writer.Written() {
return
}
if errors.Is(err, service.ErrCloud115TranscodePending) {
retryAfter := 5
message := "115 正在转码,请稍候"
var info *service.PlaybackInfo
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
info = latest
if latest.Transcode.Message != "" {
message = latest.Transcode.Message
}
if latest.Transcode.RetryAfterSec > 0 {
retryAfter = latest.Transcode.RetryAfterSec
}
}
c.JSON(http.StatusConflict, gin.H{
"code": "transcode_pending",
"message": message,
"retry_after_sec": retryAfter,
"playback": info,
})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
mediaID := c.Query("media_id")
if mediaID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
c.Param("session"),
c.Param("key"),
)
if err == nil || c.Writer.Written() {
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
}
+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)
}
}
+112 -1
View File
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if cfg.PlaybackRate != 1 {
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
@@ -133,3 +137,110 @@ 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,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
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 || cfg.PlaybackRate != 1.5 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
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.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
svc := newDanmakuSettingsService(t)
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
if initial.Vr360GuideSeen {
t.Fatal("vr360 guide should be unread for a fresh user")
}
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"vr360_guide_seen":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.Vr360GuideSeen {
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if !user.PlayerVr360GuideSeen {
t.Fatalf("guide flag not persisted on the user: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"playback_rate":4}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerPlaybackRate != 1 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
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)
}
}
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
+7 -10
View File
@@ -29,7 +29,7 @@ var embyPlaceholderPNG = []byte{
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"), false)
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
}
}
@@ -37,11 +37,11 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"), true)
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
}
}
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string, person bool) {
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
@@ -50,13 +50,10 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
embyServePlaceholderImage(c)
return
}
var raw string
var err error
if person {
raw, err = svc.Emby.PersonImageURL(ctx, id, imageType, tag)
} else {
raw, err = svc.Emby.ImageURL(ctx, id, imageType)
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
+7 -34
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +97,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +182,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
parentID = c.Param("id")
}
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
+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
}
@@ -0,0 +1,63 @@
package handler
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) {
imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatal(err)
}
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/t/p/w300/actor.jpg" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageData)
}))
defer imageServer.Close()
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: t.TempDir()},
Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"},
}
tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil)
emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb)
proxy := service.NewImageProxy(cfg, zap.NewNop())
imageURL, _ := url.Parse(imageServer.URL)
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} })
svc := &service.Container{Emby: emby, ImageProxy: proxy}
raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" {
t.Fatalf("resolved raw=%q err=%v", raw, err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "person~tmdb~101"},
{Key: "type", Value: "Primary"},
}
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil)
embyItemImageHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if got := len(w.Body.Bytes()); got != len(imageData) {
t.Fatalf("image bytes=%d want %d", got, len(imageData))
}
}
+2 -8
View File
@@ -13,10 +13,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
+10 -9
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
}
@@ -85,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -109,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
+1 -4
View File
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2
View File
@@ -19,11 +19,13 @@ 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))
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+133 -91
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,
})
@@ -691,7 +722,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -699,25 +732,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) {
@@ -727,6 +760,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
// 画质与原文件一致,不触发转码。
if wantSameOriginProxy(c) {
handled, proxyErr := proxySTRMStream(c, svc, m)
if handled {
writeProxyError(c, proxyErr)
return
}
}
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+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
}
+40
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
type libraryTagsReq struct {
Tags []model.LibraryTagSet `json:"tags"`
}
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req libraryTagsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tags == nil {
tags = []model.LibraryTagSet{}
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
+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)
}
}
@@ -12,6 +12,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
@@ -44,6 +46,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
authed.GET("/media/:id/playback", mediaPlaybackInfoHandler(svc))
authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
@@ -65,6 +69,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
authed.GET("/img", imageProxyHandler(svc))
}
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/me/library-tags",
"PUT /api/me/library-tags",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
+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 = sortRemoteSeriesCards(cards, sortSpec, 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 {
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"slices"
"time"
"github.com/truewhile/MeBox/internal/service"
)
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
if spec.Field != "updated_at" {
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
}
out := append([]service.SeriesCard(nil), cards...)
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
if spec.Order == "asc" {
slices.Reverse(out)
}
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import (
"testing"
"github.com/truewhile/MeBox/internal/service"
)
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
}
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
}
}
+56
View File
@@ -0,0 +1,56 @@
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
//
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
// 避免把网盘流量无谓地压到服务器上。
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
func wantSameOriginProxy(c *gin.Context) bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
if c == nil || svc == nil || svc.Strm == nil || m == nil {
return false, nil
}
if !service.IsStrmMediaRow(m) {
return false, nil
}
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
return false, nil
}
return true, err
}
return true, nil
}
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
func writeProxyError(c *gin.Context, err error) {
if c == nil || err == nil || c.Writer.Written() {
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+20 -10
View File
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return
}
now := time.Now()
acct.LastTestAt = &now
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
result := ""
ok := false
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
result = err.Error()
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
result = "ok"
ok = true
}
} else {
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
acct.LastTestAt = &now
acct.LastTestResult = result
acct.LastTestOK = ok
if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil {
// 写库失败时仍返回本地测试结果;不要回退到整行 Update,
// 那会覆盖 TestConnection 期间可能刷新的账号配置。
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil {
acct = fresh
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
}
}
+27
View File
@@ -3,6 +3,9 @@ package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
@@ -63,6 +66,7 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"POST /api/admin/strm/uploads/retry-failed",
"POST /api/admin/strm/uploads/cancel-pending",
"GET /api/strm/play/:provider/:file",
"HEAD /api/strm/play/:provider/:file",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
@@ -108,4 +112,27 @@ func TestStrmAccountsCRUD(t *testing.T) {
if w.Code == http.StatusInternalServerError {
t.Fatalf("strm play endpoint errored: %d", w.Code)
}
videoPath := filepath.Join(t.TempDir(), "sample.mkv")
if err := os.WriteFile(videoPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write test video: %v", err)
}
if err := repos.StrmSyncPath.Create(t.Context(), &model.StrmSyncPath{
Name: "local-test",
Provider: model.StrmProviderLocal,
RemotePath: filepath.Dir(videoPath),
Enabled: true,
}); err != nil {
t.Fatalf("create local sync path: %v", err)
}
// VidHub 等客户端会先用 HEAD 探测 STRM 播放源,不能返回 405/404。
req = httptest.NewRequest(http.MethodHead, "/api/strm/play/local/video.mkv?path="+url.QueryEscape(videoPath), nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("HEAD strm play endpoint = %d, want 200", w.Code)
}
if w.Body.Len() != 0 {
t.Fatalf("HEAD strm play endpoint returned body: %q", w.Body.String())
}
}
+32 -40
View File
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
var rows []model.PlaybackHistory
if err := svc.Repo.DB.
Where("user_id = ? AND completed = ?", toString(uid), false).
Where("user_id = ? AND completed = ?", userID, false).
Order("watched_at desc").
Limit(limit).
Find(&rows).Error; err != nil {
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
mIdx := make(map[string]model.Media, len(media))
for _, m := range media {
if !mediaVisibleForRequest(c, svc, &m) {
continue
}
mIdx[m.ID] = m
}
out := make([]gin.H, 0, len(rows))
staleIDs := make([]string, 0)
for _, r := range rows {
m, ok := mIdx[r.MediaID]
if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
if ok {
if mediaVisibleForRequest(c, svc, &m) {
out = append(out, gin.H{
"history": r,
"media": *fallback,
"media": m,
})
}
continue
}
out = append(out, gin.H{
"history": r,
"media": m,
})
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if mediaVisibleForRequest(c, svc, remoteMedia) {
out = append(out, gin.H{
"history": r,
"media": *remoteMedia,
})
}
continue
}
}
}
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
// 因此清理这条失效播放记录,不再占用继续观看列表。
staleIDs = append(staleIDs, r.MediaID)
}
if len(staleIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
Delete(&model.PlaybackHistory{}).Error
}
c.JSON(http.StatusOK, out)
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+105
View File
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
valid := model.Media{
Base: model.Base{ID: "media-valid"},
Title: "有效影片",
Path: "/media/valid.mkv",
}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-valid"},
UserID: "user-1",
MediaID: valid.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-stale"},
UserID: "user-1",
MediaID: "media-deleted",
PositionMs: 60_000,
DurationMs: 120_000,
WatchedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history/continue", historyContinueHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body []struct {
History model.PlaybackHistory `json:"history"`
Media model.Media `json:"media"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
}
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
t.Fatalf("unexpected row: %#v", body[0])
}
var staleCount int64
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
Count(&staleCount).Error; err != nil {
t.Fatal(err)
}
if staleCount != 0 {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
+2
View File
@@ -125,6 +125,8 @@ func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool {
return len(segments) >= 3 &&
strings.EqualFold(segments[2], "play") &&
strings.TrimSpace(c.Query("media_id")) == mediaID
case "cloud115":
return strings.TrimSpace(c.Query("media_id")) == mediaID
}
}
return false
+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())
}
}
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+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 将媒体项标记为给定用户的收藏。
+21 -4
View File
@@ -28,13 +28,29 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
// 用户的开关影响其他人。
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
@@ -95,4 +111,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}
+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
}
+15
View File
@@ -57,6 +57,21 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount
})
}
// UpdateTestResult updates only connectivity-test metadata. Callers that touch
// account credentials must not use Update with a snapshot read before Ping:
// a 115 token refresh can persist new tokens while Ping is running, and writing
// the stale snapshot back would revoke the freshly rotated credentials.
func (r *StrmAccountRepository) UpdateTestResult(ctx context.Context, id string, at time.Time, result string, ok bool) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", id).Updates(map[string]any{
"last_test_at": at,
"last_test_result": result,
"last_test_ok": ok,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error
+186
View File
@@ -0,0 +1,186 @@
// 115 开放平台视频播放/转码 API。
//
// 播放接口返回的是 115 云端转码后的 HLS(m3u8)地址,不是原文件
// downurl;未完成转码时接口会返回 state=false,可再调用 video_push
// 请求加速转码。
package cloud115
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
)
// FlexString 兼容 115 接口同字段在不同设备端返回字符串或数字的情况。
type FlexString string
func (s *FlexString) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
*s = ""
return nil
}
var str string
if err := json.Unmarshal(raw, &str); err == nil {
*s = FlexString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
*s = FlexString(num.String())
return nil
}
return fmt.Errorf("115: 无法解析字符串/数字字段 %s", string(raw))
}
func (s FlexString) String() string {
return string(s)
}
func (s FlexString) Int64() int64 {
v, _ := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 64)
return v
}
// MultiTrack 是 115 返回的一条音轨信息。
type MultiTrack struct {
Title string `json:"title"`
IsSelected string `json:"is_selected"`
SyncTime string `json:"sync_time"`
}
// MultiTrackList 兼容 115 接口把 multitrack_list 返回成对象(键为音轨序号)
// 或数组的两种形态。
type MultiTrackList []MultiTrack
func (l *MultiTrackList) UnmarshalJSON(data []byte) error {
raw := bytes.TrimSpace(data)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte("{}")) {
*l = nil
return nil
}
if raw[0] == '[' {
var arr []MultiTrack
if err := json.Unmarshal(raw, &arr); err != nil {
return err
}
*l = arr
return nil
}
if raw[0] == '{' {
var m map[string]MultiTrack
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
keys := make([]int, 0, len(m))
for key := range m {
idx, err := strconv.Atoi(strings.TrimSpace(key))
if err != nil {
continue
}
keys = append(keys, idx)
}
sort.Ints(keys)
out := make([]MultiTrack, 0, len(keys))
for _, idx := range keys {
out = append(out, m[strconv.Itoa(idx)])
}
*l = out
return nil
}
return fmt.Errorf("115: multitrack_list 既不是对象也不是数组")
}
// VideoURLItem 是 115 返回的一条清晰度播放地址。
type VideoURLItem struct {
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
Definition int `json:"definition"`
Title string `json:"title"`
DefinitionN int `json:"definition_n"`
}
// VideoPlayData 是 /open/video/play 的 data 字段。
type VideoPlayData struct {
FileID string `json:"file_id"`
ParentID string `json:"parent_id"`
FileName string `json:"file_name"`
FileSize FlexString `json:"file_size"`
FileSha1 string `json:"file_sha1"`
FileType string `json:"file_type"`
IsPrivate FlexString `json:"is_private"`
PlayLong FlexString `json:"play_long"`
UserDef int `json:"user_def"`
UserRotate int `json:"user_rotate"`
UserTurn int `json:"user_turn"`
MultitrackList MultiTrackList `json:"multitrack_list"`
DefinitionList map[string]string `json:"definition_list"`
DefinitionListNew map[string]string `json:"definition_list_new"`
VideoURL []VideoURLItem `json:"video_url"`
VideoPushState *bool `json:"video_push_state"`
}
// GetVideoPlayInfo 调用 115 视频在线播放接口。
//
// 注意:state=false(例如视频尚未转码)时 doAuthJSON 会返回 error,但仍会
// 返回已解析的 RespBase/VideoPlayData。调用方需要同时检查两者:有 data 时
// 可用于判断需要等待还是触发加速转码。
func (c *OpenClient) GetVideoPlayInfo(ctx context.Context, pickCode, ua string) (*RespBase, *VideoPlayData, error) {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return nil, nil, fmt.Errorf("115: pick_code 为空")
}
resp, err := c.doAuthJSONWithUA(
ctx,
http.MethodGet,
ProAPIBase+"/open/video/play",
map[string]string{"pick_code": pickCode},
1,
ua,
)
if resp == nil {
return nil, nil, err
}
data := &VideoPlayData{}
if len(resp.Data) > 0 {
trimmed := bytes.TrimSpace(resp.Data)
if !bytes.Equal(trimmed, []byte("null")) {
if decodeErr := json.Unmarshal(trimmed, data); decodeErr != nil && err == nil {
err = fmt.Errorf("115: 解析视频播放信息失败:%w", decodeErr)
}
}
}
return resp, data, err
}
// SubmitVideoPush 提交 115 加速转码请求。
//
// op 支持 vip_push(按 VIP 等级加速)和 pay_push(消耗枫币)。调用方必须
// 明确选择 op,默认空值按 vip_push 处理,避免误消费枫币。
func (c *OpenClient) SubmitVideoPush(ctx context.Context, pickCode, op string) error {
pickCode = strings.TrimSpace(pickCode)
if pickCode == "" {
return fmt.Errorf("115: pick_code 为空")
}
op = strings.TrimSpace(op)
if op == "" {
op = "vip_push"
}
_, err := c.doAuthJSON(
ctx,
http.MethodPost,
ProAPIBase+"/open/video/video_push",
map[string]string{
"pick_code": pickCode,
"op": op,
},
1,
)
return err
}
+361
View File
@@ -0,0 +1,361 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
)
const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
)
type cloud115HLSSession struct {
ID string
MediaID string
Definition int
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
entries map[string]string
next int
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
//
// 浏览器不能直接请求 115 的 m3u8:master/variant/分片的 CORS 只允许
// https://115.com,且 master 还是 HTTP。代理在服务端拉取并重写播放列表,
// 分片按 Range 流式转发。
type Cloud115HLSProxy struct {
service *Cloud115PlaybackService
client *http.Client
mu sync.Mutex
sessions map[string]*cloud115HLSSession
}
func newCloud115HLSProxy(service *Cloud115PlaybackService) *Cloud115HLSProxy {
return &Cloud115HLSProxy{
service: service,
client: &http.Client{
Timeout: 0,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 6 {
return errors.New("stopped after 6 redirects")
}
return nil
},
},
sessions: make(map[string]*cloud115HLSSession),
}
}
// ServeMaster 解析指定清晰度并返回重写后的 master.m3u8。
func (p *Cloud115HLSProxy) ServeMaster(ctx context.Context, w http.ResponseWriter, r *http.Request, mediaID string, definition int) error {
if p == nil || p.service == nil {
return ErrCloud115NotApplicable
}
upstream, _, err := p.service.ResolveCloud115URL(ctx, mediaID, definition)
if err != nil {
return err
}
session := p.newSession(mediaID, definition)
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := p.fetchUpstream(fetchCtx, upstream, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("115 云端播放列表返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
p.storeSession(session)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
// ServeChild 代理 variant/分片;variant 播放列表会继续重写为同源地址。
func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter, r *http.Request, sessionID, key string) error {
session, upstream, ok := p.lookup(sessionID, key)
if !ok {
return ErrCloud115HLSSessionNotFound
}
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusGone {
p.deleteSession(sessionID)
return ErrCloud115HLSUpstreamExpired
}
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
isPlaylist := strings.Contains(contentType, "mpegurl") ||
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
}
copyUpstreamHeaders(w, resp, isPlaylist)
if isPlaylist {
body, err := io.ReadAll(io.LimitReader(resp.Body, cloud115HLSMaxManifest))
if err != nil {
return err
}
baseURL := upstream
if resp.Request != nil && resp.Request.URL != nil {
baseURL = resp.Request.URL.String()
}
rewritten := p.rewriteManifest(session, string(body), baseURL, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.WriteString(w, rewritten)
}
return err
}
w.WriteHeader(resp.StatusCode)
if r.Method != http.MethodHead {
_, err = io.Copy(w, resp.Body)
}
return err
}
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
now := time.Now()
return &cloud115HLSSession{
ID: cloud115.RandomString(24),
MediaID: mediaID,
Definition: definition,
CreatedAt: now,
ExpiresAt: now.Add(cloud115HLSSessionTTL),
entries: make(map[string]string),
}
}
func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
if p == nil || session == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if len(p.sessions) >= cloud115HLSMaxSessions {
for id, existing := range p.sessions {
if now.After(existing.ExpiresAt) {
delete(p.sessions, id)
}
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
p.sessions[session.ID] = session
}
func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, string, bool) {
if p == nil {
return nil, "", false
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
}
p.mu.Unlock()
if session == nil {
return nil, "", false
}
session.mu.Lock()
upstream, ok := session.entries[key]
session.mu.Unlock()
return session, upstream, ok
}
func (p *Cloud115HLSProxy) deleteSession(sessionID string) {
if p == nil {
return
}
p.mu.Lock()
delete(p.sessions, sessionID)
p.mu.Unlock()
}
func (p *Cloud115HLSProxy) fetchUpstream(ctx context.Context, rawURL, rangeHeader string) (*http.Response, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed == nil || !isAllowed115UpstreamHost(parsed.Hostname()) {
return nil, fmt.Errorf("115 hls upstream host not allowed: %s", rawURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", cloud115.DefaultUA)
if strings.TrimSpace(rangeHeader) != "" {
req.Header.Set("Range", rangeHeader)
}
return p.client.Do(req)
}
func isAllowed115UpstreamHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
for _, suffix := range []string{".115.com", ".115cdn.com", ".115cdn.net"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return host == "115.com" || host == "115cdn.com" || host == "115cdn.net"
}
func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, baseURL, rawQuery string) string {
if session == nil {
return text
}
lines := strings.SplitAfter(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
if strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") ||
strings.HasPrefix(trimmed, "#EXT-X-KEY:") ||
strings.HasPrefix(trimmed, "#EXT-X-MAP:") {
lines[i] = replaceManifestURI(line, func(uri string) string {
return p.proxyURL(session, resolveManifestURL(baseURL, uri), rawQuery)
})
}
continue
}
lines[i] = p.proxyURL(session, resolveManifestURL(baseURL, trimmed), rawQuery) + lineEnding(line)
}
return strings.Join(lines, "")
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
mediaID := session.MediaID
session.mu.Unlock()
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
}
func childProxyQuery(rawQuery, mediaID string) string {
values, _ := url.ParseQuery(rawQuery)
keep := url.Values{}
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey", "profile_id", "profile_pin_token"} {
if value := strings.TrimSpace(values.Get(key)); value != "" {
keep.Set(key, value)
}
}
if strings.TrimSpace(mediaID) != "" {
keep.Set("media_id", mediaID)
}
return keep.Encode()
}
func replaceManifestURI(line string, replace func(string) string) string {
const marker = `URI="`
idx := strings.Index(line, marker)
if idx < 0 {
return line
}
start := idx + len(marker)
end := strings.Index(line[start:], `"`)
if end < 0 {
return line
}
end += start
return line[:start] + replace(line[start:end]) + line[end:]
}
func resolveManifestURL(baseURL, raw string) string {
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
ref, refErr := url.Parse(strings.TrimSpace(raw))
if baseErr != nil || refErr != nil || base == nil || ref == nil {
return raw
}
return base.ResolveReference(ref).String()
}
func lineEnding(line string) string {
if strings.HasSuffix(line, "\r\n") {
return "\r\n"
}
if strings.HasSuffix(line, "\n") {
return "\n"
}
return ""
}
func copyUpstreamHeaders(w http.ResponseWriter, resp *http.Response, playlist bool) {
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(key); value != "" {
w.Header().Set(key, value)
}
}
if playlist {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
} else if w.Header().Get("Cache-Control") == "" {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}
+151
View File
@@ -0,0 +1,151 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720\nhttps://cpats01.115.com/a.m3u8?u=1&se=2\n"
rewritten := proxy.rewriteManifest(session, manifest, "http://videoplay.115.com/m3u8/pc", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
t.Fatalf("auth/media query missing: %s", rewritten)
}
if len(session.entries) != 1 {
t.Fatalf("session entries = %d, want 1", len(session.entries))
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := `#EXTM3U
#EXT-X-KEY:METHOD=AES-128,URI="https://cpats01.115.com/key?k=1"
#EXTINF:10.0,
https://cpats01.115.com/seg.ts?x=1
`
rewritten := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "token=t&media_id=media-1")
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten key manifest: %s", rewritten)
}
if len(session.entries) != 2 {
t.Fatalf("session entries = %d, want 2", len(session.entries))
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be allowed", host)
}
}
for _, host := range []string{"", "evil.example.com", "115.com.evil.example.com"} {
if isAllowed115UpstreamHost(host) {
t.Fatalf("host %q should be rejected", host)
}
}
}
func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := "#EXTM3U\n#EXT-X-TARGETDURATION:10\nhttps://cpats01.115.com/seg.ts?x=1\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/vnd.apple.mpegurl"},
},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
t.Fatalf("upstream URL leaked in variant: %s", rec.Body.String())
}
}
func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
payload := []byte("segment-bytes")
proxy := &Cloud115HLSProxy{
sessions: map[string]*cloud115HLSSession{},
}
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusPartialContent,
Header: http.Header{
"Content-Type": []string{"video/mp2t"},
"Content-Range": []string{"bytes 0-12/100"},
"Content-Length": []string{strconv.Itoa(len(payload))},
},
Body: io.NopCloser(bytes.NewReader(payload)),
Request: req,
}, nil
})}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
req.Header.Set("Range", "bytes=0-12")
rec := httptest.NewRecorder()
if err := proxy.ServeChild(context.Background(), rec, req, session.ID, "e1"); err != nil {
t.Fatalf("ServeChild: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if rec.Header().Get("Content-Range") != "bytes 0-12/100" {
t.Fatalf("content-range = %q", rec.Header().Get("Content-Range"))
}
if !bytes.Equal(rec.Body.Bytes(), payload) {
t.Fatalf("payload = %q, want %q", rec.Body.Bytes(), payload)
}
}
+411
View File
@@ -0,0 +1,411 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var (
ErrCloud115TranscodePending = errors.New("115 cloud transcode pending")
ErrCloud115NotApplicable = errors.New("115 cloud playback not applicable")
)
// Cloud115TranscodeState 描述当前请求清晰度的云端转码状态。
type Cloud115TranscodeState struct {
State string `json:"state"` // idle / ready / transcoding / unavailable
Definition string `json:"definition,omitempty"`
Message string `json:"message,omitempty"`
RetryAfterSec int `json:"retry_after_sec,omitempty"`
StartedAt int64 `json:"started_at,omitempty"`
}
// PlaybackInfo 是播放器加载时获取的统一播放能力描述。
type PlaybackInfo struct {
MediaID string `json:"media_id"`
Provider string `json:"provider"`
Fallback []string `json:"fallback"`
DefaultQuality string `json:"default_quality"`
CloudQualities []PlaybackQuality `json:"cloud_qualities,omitempty"`
LocalQualities []PlaybackQuality `json:"local_qualities"`
Transcode Cloud115TranscodeState `json:"transcode"`
}
type cloud115PushAttempt struct {
at time.Time
success bool
message string
}
// Cloud115PlaybackService 负责 115 云端播放、清晰度列表和云端转码触发。
// 实际的 HLS 代理在 Cloud115HLSProxy 中。
type Cloud115PlaybackService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
strm *StrmService
mu sync.Mutex
pushState map[string]cloud115PushAttempt
proxy *Cloud115HLSProxy
}
func NewCloud115PlaybackService(cfg *config.Config, log *zap.Logger, repo *repository.Container, strm *StrmService) *Cloud115PlaybackService {
svc := &Cloud115PlaybackService{
cfg: cfg,
log: log,
repo: repo,
strm: strm,
pushState: make(map[string]cloud115PushAttempt),
}
svc.proxy = newCloud115HLSProxy(svc)
return svc
}
// HLSProxy 返回 115 云 HLS 反向代理。
func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
if s == nil {
return nil
}
return s.proxy
}
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
//
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
if s == nil || s.repo == nil || s.repo.Media == nil {
return nil, ErrMediaNotFound
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, ErrMediaNotFound
}
provider := MediaPlaybackProvider(m)
localQualities := LocalQualityOptions(m)
localDefault := DefaultLocalHLSQualityID(m)
info := &PlaybackInfo{
MediaID: m.ID,
Provider: provider,
DefaultQuality: localDefault,
LocalQualities: localQualities,
Transcode: Cloud115TranscodeState{State: "idle"},
}
if provider != model.StrmProvider115 {
info.Fallback = []string{"direct", "local_hls"}
return info, nil
}
account, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{State: "unavailable", Message: err.Error()}
return info, nil
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
info.Fallback = []string{"direct", "local_hls"}
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Message: cloud115PlayErrorMessage(playErr, resp),
}
return info, nil
}
cloudQualities := Cloud115QualityOptions(m, data)
info.CloudQualities = cloudQualities
info.Fallback = []string{"direct", "cloud_hls", "local_hls"}
info.DefaultQuality = DefaultCloud115Quality(cloudQualities)
requested := strings.TrimSpace(strconv.Itoa(requestedDefinition))
if requestedDefinition <= 0 || requested == "0" {
requested = info.DefaultQuality
}
selected, ok := findPlaybackQuality(cloudQualities, requested)
if !ok {
info.Transcode = Cloud115TranscodeState{
State: "unavailable",
Definition: requested,
Message: "115 不支持该清晰度",
}
return info, nil
}
if selected.Source == "original" {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
if selected.Available {
info.Transcode = Cloud115TranscodeState{State: "ready", Definition: selected.ID}
return info, nil
}
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
return info, nil
}
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
return s.PlaybackInfo(ctx, mediaID, definition)
}
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
// 档位尚未转码完成时返回 ErrCloud115TranscodePending。
func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaID string, definition int) (string, *cloud115.VideoPlayData, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", nil, err
}
if m == nil {
return "", nil, ErrMediaNotFound
}
if MediaPlaybackProvider(m) != model.StrmProvider115 {
return "", nil, ErrCloud115NotApplicable
}
_, pickCode, client, err := s.resolve115Target(ctx, m)
if err != nil {
return "", nil, err
}
resp, data, playErr := client.GetVideoPlayInfo(ctx, pickCode, cloud115.DefaultUA)
if cloud115PlayInfoFatal(resp, playErr) {
return "", data, fmt.Errorf("115 云端播放不可用:%s", cloud115PlayErrorMessage(playErr, resp))
}
requested := definition
if requested <= 0 {
requested = 4
}
item := findVideoURL(data, requested)
if item == nil || strings.TrimSpace(item.URL) == "" {
return "", data, ErrCloud115TranscodePending
}
return strings.TrimSpace(item.URL), data, nil
}
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
quality PlaybackQuality,
client *cloud115.OpenClient,
) Cloud115TranscodeState {
key := accountID + ":" + pickCode
now := time.Now()
s.mu.Lock()
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
ttl = 3 * time.Hour
}
if now.Sub(last.at) < ttl {
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: last.message,
RetryAfterSec: 5,
StartedAt: last.at.Unix(),
}
}
}
s.mu.Unlock()
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
attempt := cloud115PushAttempt{at: now, success: err == nil}
if err != nil {
attempt.message = fmt.Sprintf("115 云端转码排队中(加速请求未成功:%s)", cloud115PlayErrorMessage(err, nil))
} else {
attempt.message = fmt.Sprintf("已触发 115 云端转码,正在等待 %s…", quality.Label)
}
s.mu.Lock()
s.pushState[key] = attempt
s.mu.Unlock()
return Cloud115TranscodeState{
State: "transcoding",
Definition: quality.ID,
Message: attempt.message,
RetryAfterSec: 5,
StartedAt: now.Unix(),
}
}
func (s *Cloud115PlaybackService) resolve115Target(ctx context.Context, m *model.Media) (*model.StrmAccount, string, *cloud115.OpenClient, error) {
if s == nil || s.strm == nil || s.repo == nil || s.repo.StrmAccount == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err != nil {
return nil, "", nil, err
}
raw = strings.TrimSpace(parsed)
}
if raw == "" {
return nil, "", nil, fmt.Errorf("缺少 STRM 播放目标")
}
u, err := url.Parse(raw)
if err != nil {
return nil, "", nil, err
}
if !strings.Contains(strings.ToLower(u.Path), "/api/strm/play/cloud115/") &&
!strings.Contains(strings.ToLower(u.Path), "/api/cloud/play/cloud115") {
return nil, "", nil, ErrCloud115NotApplicable
}
accountID := strings.TrimSpace(u.Query().Get("acct"))
if accountID == "" {
return nil, "", nil, fmt.Errorf("缺少 115 账号 ID")
}
pickCode := strings.TrimSpace(u.Query().Get("pickcode"))
if pickCode == "" {
return nil, "", nil, fmt.Errorf("缺少 115 pickcode")
}
account, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil {
return nil, "", nil, err
}
if account == nil || !account.Enabled {
return nil, "", nil, fmt.Errorf("115 账号不存在或已禁用")
}
if account.Provider != model.StrmProvider115 {
return nil, "", nil, ErrCloud115NotApplicable
}
provider, err := s.strm.providerFor(ctx, account)
if err != nil {
return nil, "", nil, err
}
openProvider, ok := provider.(cloud.OpenAPI115Provider)
if !ok || openProvider.OpenClient() == nil {
return nil, "", nil, ErrCloud115NotApplicable
}
return account, pickCode, openProvider.OpenClient(), nil
}
// MediaPlaybackProvider 返回媒体行的播放提供方(用于前端降级顺序)。
func MediaPlaybackProvider(m *model.Media) string {
if m == nil {
return model.StrmProviderLocal
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
if target, err := readLocalSTRMTarget(m.Path); err == nil {
raw = strings.TrimSpace(target)
}
}
if raw != "" {
if u, err := url.Parse(raw); err == nil {
path := strings.ToLower(u.Path)
if idx := strings.Index(path, "/api/strm/play/"); idx >= 0 {
rest := strings.Trim(path[idx+len("/api/strm/play/"):], "/")
if provider, _, ok := strings.Cut(rest, "/"); ok && provider != "" {
return provider
}
}
if idx := strings.Index(path, "/api/cloud/play/"); idx >= 0 {
provider := strings.Trim(path[idx+len("/api/cloud/play/"):], "/")
if provider != "" {
return provider
}
}
}
}
if IsStrmMediaRow(m) {
return "strm"
}
return model.StrmProviderLocal
}
func findPlaybackQuality(options []PlaybackQuality, id string) (PlaybackQuality, bool) {
id = strings.TrimSpace(id)
for _, option := range options {
if option.ID == id {
return option, true
}
}
return PlaybackQuality{}, false
}
func findVideoURL(data *cloud115.VideoPlayData, definition int) *cloud115.VideoURLItem {
if data == nil || definition <= 0 {
return nil
}
for i := range data.VideoURL {
item := &data.VideoURL[i]
if item.DefinitionN == definition || item.Definition == definition {
return item
}
}
return nil
}
func cloud115PlayInfoFatal(resp *cloud115.RespBase, err error) bool {
if resp == nil {
return true
}
if bool(resp.State) {
return false
}
switch resp.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid,
cloud115.TokenRefreshFail,
cloud115.RequestMaxLimitCode,
cloud115.RequestRateLimitCode:
return true
}
// 非鉴权/限流错误一律当作“尚未转码完成”处理,继续轮询。
_ = err
return false
}
func cloud115PlayErrorMessage(err error, resp *cloud115.RespBase) string {
if err != nil {
var apiErr *cloud115.OpenAPIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case cloud115.AccessTokenAuthFail,
cloud115.AccessTokenExpiryCode,
cloud115.AccessAuthInvalid,
cloud115.AccessTokenFormatInvalid,
cloud115.RefreshTokenInvalid:
return "115 授权已过期,请重新授权账号"
}
if strings.TrimSpace(apiErr.Message) != "" {
return apiErr.Message
}
}
return err.Error()
}
if resp != nil {
if msg := strings.TrimSpace(resp.Message); msg != "" {
return msg
}
if msg := strings.TrimSpace(resp.Error); msg != "" {
return msg
}
}
return "115 云端播放不可用"
}
+203
View File
@@ -0,0 +1,203 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
var playCalls int32
var pushCalls int32
var ready int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
atomic.AddInt32(&playCalls, 1)
if atomic.LoadInt32(&ready) == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{
"state": false,
"code": 0,
"message": "转码中",
"data": map[string]any{},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"state": true,
"code": 0,
"message": "",
"data": map[string]any{
"file_name": "test.mkv",
"definition_list_new": map[string]string{
"4": "1080P",
},
"video_url": []map[string]any{
{
"url": "http://cdn.example/master.m3u8",
"definition": 4,
"definition_n": 4,
"width": 1920,
"height": 1080,
"title": "1080P",
},
},
},
})
case "/open/video/video_push":
atomic.AddInt32(&pushCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-1"},
Title: "test",
Path: "/media/test.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("first playback info: %v", err)
}
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
t.Fatalf("unexpected playback info: %#v", info)
}
if info.Transcode.State != "transcoding" {
t.Fatalf("transcode state = %q, want transcoding", info.Transcode.State)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls = %d, want 1", got)
}
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
t.Fatalf("second playback info: %v", err)
}
if got := atomic.LoadInt32(&pushCalls); got != 1 {
t.Fatalf("push calls after polling = %d, want 1", got)
}
atomic.StoreInt32(&ready, 1)
info, err = svc.PlaybackInfo(ctx, media.ID, 0)
if err != nil {
t.Fatalf("ready playback info: %v", err)
}
if info.Transcode.State != "ready" {
t.Fatalf("transcode state = %q, want ready", info.Transcode.State)
}
url, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4)
if err != nil {
t.Fatalf("resolve cloud url: %v", err)
}
if url != "http://cdn.example/master.m3u8" {
t.Fatalf("url = %q", url)
}
if got := atomic.LoadInt32(&playCalls); got < 3 {
t.Fatalf("play calls = %d, want >= 3", got)
}
}
func TestCloud115PlaybackInfoTranscodePendingBeforeReady(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/open/video/play":
_ = json.NewEncoder(w).Encode(map[string]any{"state": false, "code": 0, "message": "转码中", "data": map[string]any{}})
case "/open/video/video_push":
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
}
}))
defer server.Close()
oldBase := cloud115.ProAPIBase
cloud115.ProAPIBase = server.URL
defer func() { cloud115.ProAPIBase = oldBase }()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.Media{})
repos := repository.New(db)
strm := NewStrmService(nil, zap.NewNop(), repos, NewCryptoService("test-secret", zap.NewNop()))
ctx := context.Background()
account, err := strm.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at",
"refresh_token": "rt",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
media := &model.Media{
Base: model.Base{ID: "media-2"},
Title: "test",
Path: "/media/test2.strm",
Container: "strm",
Height: 1080,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + account.ID + "&pickcode=pc2",
}
if err := db.Create(media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
if _, _, err := svc.ResolveCloud115URL(ctx, media.ID, 4); !errors.Is(err, ErrCloud115TranscodePending) {
t.Fatalf("resolve pending err = %v, want ErrCloud115TranscodePending", err)
}
}
func TestMediaPlaybackProvider(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"115", "/api/strm/play/cloud115/video.mkv?acct=1&pickcode=pc", model.StrmProvider115},
{"openlist", "/api/strm/play/openlist/video.mkv?acct=1&ref=/a.mkv", model.StrmProviderOpenList},
{"local", "/media/local.mkv", model.StrmProviderLocal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
media := &model.Media{Path: tc.raw}
if tc.name != "local" {
media.STRMURL = tc.raw
}
got := MediaPlaybackProvider(media)
if got != tc.want {
t.Fatalf("provider = %q, want %q", got, tc.want)
}
})
}
}
+299 -47
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"
@@ -49,6 +49,12 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
const (
minPlayerPlaybackRate = 0.5
maxPlayerPlaybackRate = 3.0
defaultPlayerPlaybackRate = 1.0
)
// danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source.
// A package var (not a const) so tests can point it at a local server.
@@ -56,21 +62,62 @@ 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"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// 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"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
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 +244,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 +256,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
@@ -297,9 +358,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
Opacity: "1",
FontSize: "24",
Area: "1",
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -319,39 +382,191 @@ 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,
PlaybackRate: defaultPlayerPlaybackRate,
}
}
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
}
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
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,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
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 patch.PlaybackRate != nil {
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
return DanmakuRenderConfig{}, fmt.Errorf(
"%w: playback_rate must be between %.2f and %.2f",
ErrInvalidDanmakuSettings,
minPlayerPlaybackRate,
maxPlayerPlaybackRate,
)
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
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 +603,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 +627,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 +636,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 +646,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 +1100,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 +1464,9 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary,
}
lastErr = err
if i < len(bases)-1 {
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(base)),
zap.Error(redactSensitiveError(err)))
}
}
return "", "auto", lastErr
@@ -1232,7 +1482,9 @@ func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configu
if err == nil {
return candidates, configured, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
s.log.Warn("danmaku search failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(configured)),
zap.Error(redactSensitiveError(err)))
}
candidates, err := s.searchCandidates(ctx, official, name, episode)
return candidates, official, err
@@ -1366,9 +1618,9 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
if err != nil {
s.log.Debug("danmaku configured lookup failed",
zap.String("source", configured),
zap.String("source", redactSensitiveURL(configured)),
zap.String("anime", m.AnimeTitle),
zap.Error(err))
zap.Error(redactSensitiveError(err)))
return nil, false
}
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
+42
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,43 @@ 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
user.PlayerPlaybackRate = 1.25
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, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
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"))
}
+11
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
@@ -198,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
+255 -58
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -37,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
userData, _ := out["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
out["UserData"] = userData
}
userData["IsFavorite"] = true
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -120,44 +113,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.RemoteLatestForDisplay(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 +243,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 +251,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 +264,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 +280,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})
@@ -369,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
favSet := map[string]bool{}
if len(mediaIDs) > 0 {
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
Find(&favs).Error; err != nil {
return nil, err
}
for _, fav := range favs {
favSet[fav.MediaID] = true
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
@@ -393,7 +496,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 +504,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, favSet[h.MediaID], 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"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
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 +585,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 +643,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 +693,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 +705,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
@@ -96,10 +96,10 @@ func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
if hits.Load() != 1 {
t.Fatalf("TMDb credits hits=%d want 1", hits.Load())
}
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg" {
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
t.Fatalf("unexpected person: %#v", people[0])
}
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg")
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
if err != nil {
t.Fatal(err)
}
+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
}
+203 -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
}
@@ -840,6 +860,180 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
return out, nil
}
// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series;
// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID
// 后无法在 Series 卡片列表中找到对应条目。
func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if remoteCollectionLooksEpisodic(mount.CollectionType) {
return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit)
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
if remoteItemsContainEpisodes(items) {
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
return items, nil
}
// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持
// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到
// 对应 Series。
//
// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards /
// Emby 客户端列剧集方式一致。
func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 多取一些以便滤掉中间容器后仍够 limit。
q.Set("Limit", strconv.Itoa(limit*4))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
filtered := make([]map[string]any, 0, limit)
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
filtered = append(filtered, it)
if len(filtered) >= limit {
break
}
}
if len(filtered) > 0 {
RewriteEmbyRemoteIDs(filtered, mount.ID)
return filtered, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
return remoteSeriesItemsFromLatest(mount, items, limit), nil
}
func remoteCollectionLooksEpisodic(collectionType string) bool {
switch strings.ToLower(strings.TrimSpace(collectionType)) {
case "tvshows", "tv":
return true
default:
return false
}
}
func remoteItemsContainEpisodes(items []map[string]any) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
return true
}
}
return false
}
func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 20
}
out := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if len(out) >= limit {
break
}
if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") {
id := strings.TrimSpace(remoteItemString(item, "Id"))
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, item)
continue
}
seriesID := remoteItemSeriesID(item, mount)
if seriesID == "" {
continue
}
if _, exists := seen[seriesID]; exists {
continue
}
seen[seriesID] = struct{}{}
out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID))
}
return out
}
func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string {
seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId"))
if seriesID == "" {
return ""
}
if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok {
if mount == nil {
return rawID
}
return EncodeEmbyRemoteID(mount.ID, rawID)
}
if mount == nil {
return seriesID
}
return EncodeEmbyRemoteID(mount.ID, seriesID)
}
func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any {
name := strings.TrimSpace(remoteItemString(episode, "SeriesName"))
if name == "" {
name = strings.TrimSpace(remoteItemString(episode, "Name"))
}
out := map[string]any{
"Id": seriesID,
"Name": name,
"Type": "Series",
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
}
if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" {
out["ImageTags"] = map[string]any{"Primary": tag}
}
year := remoteItemInt(episode, "SeriesProductionYear")
if year == 0 {
year = remoteItemInt(episode, "SeriesYear")
}
if year == 0 {
year = remoteItemInt(episode, "ProductionYear")
}
if year > 0 {
out["ProductionYear"] = year
}
for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} {
if value, ok := episode[key]; ok {
out[key] = value
}
}
return out
}
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
@@ -1109,7 +1303,7 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
if value := resp.Header.Get(header); value != "" {
@@ -1269,12 +1463,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
return nil
}
+91 -80
View File
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
seriesID = rid
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
rating := remoteItemFloat(item, "CommunityRating")
if rating == 0 {
rating = remoteItemFloat(item, "CriticRating")
}
year := remoteItemInt(item, "ProductionYear")
if year == 0 {
year = remoteItemInt(item, "Year")
}
if year == 0 {
year = remoteItemInt(item, "SeriesProductionYear")
}
if year == 0 {
year = remoteItemInt(item, "SeriesYear")
}
media := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
Title: remoteItemString(item, "Name"),
OriginalName: remoteItemString(item, "OriginalTitle"),
Overview: remoteItemString(item, "Overview"),
Year: year,
Rating: float32(rating),
Path: remoteItemString(item, "Path"),
Genres: remoteItemGenres(item),
ScrapeStatus: "done",
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
media.CreatedAt = date
media.UpdatedAt = date
@@ -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
}
@@ -492,19 +509,24 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
return items, total, nil
}
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
// 直接解析失败,因此按 200 条翻页拉全量。
const remoteSeriesPageSize = 200
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按
// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器
// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded
// 时填充。
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
// v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。
cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID)
var cached []SeriesCard
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
return cached, nil
@@ -512,19 +534,17 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("Recursive", "true")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
q.Set("Limit", "200")
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
cards := make([]SeriesCard, 0)
for startIndex := 0; ; startIndex += 200 {
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
q.Set("StartIndex", strconv.Itoa(startIndex))
body.Items = nil
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
@@ -534,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
break
}
for _, it := range body.Items {
name := remoteItemString(it, "Name")
path := remoteItemString(it, "Path")
if remoteSeriesItemLooksLikeContainer(name, path) {
continue
}
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
@@ -548,9 +572,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: true,
LastAddedAt: lastAdded,
})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize {
break
}
}
@@ -560,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
return cards, nil
}
func remoteSeriesItemLooksLikeContainer(name, path string) bool {
if isEmbyGenericContainer(name) {
return true
}
base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
return base != "" && isEmbyGenericContainer(base)
}
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
@@ -572,52 +611,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
return cached, nil
}
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
q := url.Values{}
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
q.Set("Limit", strconv.Itoa(limit))
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
}
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
if err != nil {
return nil, err
}
cards := make([]SeriesCard, 0, len(items))
for _, it := range items {
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
var lastAdded *time.Time
if !m.UpdatedAt.IsZero() {
t := m.UpdatedAt
@@ -630,7 +631,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
if count == 0 {
count = 1
}
cards = append(cards, SeriesCard{
Key: m.ID,
Rep: m,
LinkMedia: m,
Count: count,
IsSeries: isSeries,
LastAddedAt: lastAdded,
})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
+448 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -120,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" {
w.WriteHeader(http.StatusBadRequest)
return
}
if q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -230,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
@@ -465,6 +476,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
if cards[0].Count != 12 {
t.Fatalf("expected Count 12, got %d", cards[0].Count)
}
if !cards[0].IsSeries {
t.Fatalf("expected remote latest card to be marked as series")
}
if cards[0].Rep.PosterURL == "" {
t.Fatalf("expected PosterURL not empty")
}
@@ -476,6 +490,439 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
}
}
func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) {
var seriesQueryCalled atomic.Bool
var latestCalled atomic.Bool
var requestedSeriesType atomic.Value
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
seriesQueryCalled.Store(true)
requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes"))
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
latestCalled.Store(true)
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-2",
"Name": "第二集",
"Type": "Episode",
"SeriesId": "series-b",
"SeriesName": "剧集 B",
"SeriesProductionYear": 2025,
"SeriesPrimaryImageTag": "poster-b",
},
{
"Id": "episode-1",
"Name": "第一集",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
"SeriesProductionYear": 2024,
},
{
"Id": "episode-1b",
"Name": "第一集下",
"Type": "Episode",
"SeriesId": "series-a",
"SeriesName": "剧集 A",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-tv-fallback"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-tv-fallback"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if !seriesQueryCalled.Load() || !latestCalled.Load() {
t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load())
}
if got, _ := requestedSeriesType.Load().(string); got != "Series" {
t.Fatalf("series query IncludeItemTypes = %q, want Series", got)
}
if len(cards) != 2 {
t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards)
}
wantKeys := []string{
EncodeEmbyRemoteID(mount.ID, "series-b"),
EncodeEmbyRemoteID(mount.ID, "series-a"),
}
for i, want := range wantKeys {
if cards[i].Key != want {
t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want)
}
if !cards[i].IsSeries {
t.Fatalf("card[%d] should be marked as series", i)
}
}
}
func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch strings.TrimPrefix(r.URL.Path, "/emby") {
case "/Users/remote-user/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
case "/Users/remote-user/Items/Latest":
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "episode-10",
"Name": "第十集",
"Type": "Episode",
"SeriesId": "series-10",
"SeriesName": "远程剧集",
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
db := newServiceTestDB(t,
&model.Setting{},
&model.User{},
&model.Favorite{},
&model.PlaybackHistory{},
&model.StrmAccount{},
&model.EmbyMount{},
)
repos := repository.New(db)
remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-emby-latest"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-emby-latest"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10)
if err != nil {
t.Fatalf("LatestItems failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 series item, got %d: %#v", len(items), items)
}
if items[0]["Type"] != "Series" {
t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"])
}
wantID := EncodeEmbyRemoteID(mount.ID, "series-10")
if items[0]["Id"] != wantID {
t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID)
}
}
// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集
// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。
func TestRemoteSeriesCardsFetchAllPages(t *testing.T) {
const totalSeries = remoteSeriesPageSize*2 + 50
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
if query.Get("Recursive") != "true" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Recursive=true"))
return
}
startIndex, _ := strconv.Atoi(query.Get("StartIndex"))
limit, _ := strconv.Atoi(query.Get("Limit"))
requests.Add(1)
items := make([]map[string]any, 0, limit)
for i := startIndex; i < totalSeries && len(items) < limit; i++ {
items = append(items, map[string]any{
"Id": "series-" + strconv.Itoa(i),
"Name": "剧集 " + strconv.Itoa(i),
"Type": "Series",
"RecursiveItemCount": 12,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": items,
"TotalRecordCount": totalSeries,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-series-pages"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-series-pages"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if len(cards) != totalSeries {
t.Fatalf("expected all %d series, got %d", totalSeries, len(cards))
}
if got := requests.Load(); got != 3 {
t.Fatalf("expected 3 paged requests, got %d", got)
}
lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1))
if cards[len(cards)-1].Key != lastKey {
t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey)
}
}
// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。
func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) {
var sawRecursive atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" {
http.NotFound(w, r)
return
}
if q.Get("Recursive") == "true" {
sawRecursive.Store(true)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 5,
"Items": []map[string]any{
{"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086},
{"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567},
{"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24},
{"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12},
{"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-recursive"},
Name: "nijigem",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-recursive"},
AccountID: acct.ID,
RemoteViewID: "view-2023",
RemoteViewName: "2023前 动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if !sawRecursive.Load() {
t.Fatal("expected Recursive=true on remote Items request")
}
if len(cards) != 3 {
t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards))
}
for _, card := range cards {
if strings.EqualFold(card.Rep.Title, "anime") {
t.Fatalf("container title %q should have been filtered", card.Rep.Title)
}
}
}
// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后
// 媒体库页找不到目标剧集,只能退回整库列表。
func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) {
var seriesQueryCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" {
http.NotFound(w, r)
return
}
seriesQueryCalls.Add(1)
parentID := r.URL.Query().Get("ParentId")
if parentID == "series-latest" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": 0,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
"Id": "series-latest",
"Name": "刚刚更新",
"Type": "Series",
"RecursiveItemCount": 24,
},
},
"TotalRecordCount": 1,
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "remote-user",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-latest-key"},
Name: "tv-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-latest-key"},
AccountID: acct.ID,
RemoteViewID: "view-tv",
RemoteViewName: "电视剧",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10)
if err != nil {
t.Fatalf("RemoteLatestCards failed: %v", err)
}
if len(latestCards) != 1 || !latestCards[0].IsSeries {
t.Fatalf("expected 1 series latest card, got %#v", latestCards)
}
seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID)
if err != nil {
t.Fatalf("RemoteSeriesCards failed: %v", err)
}
if seriesQueryCalls.Load() < 2 {
t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load())
}
found := false
for _, card := range seriesCards {
if card.Key == latestCards[0].Key {
found = true
break
}
}
if !found {
t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards)
}
}
func TestRemoteLatestFields(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+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":
+118 -50
View File
@@ -11,8 +11,8 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
return err
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if e.remote != nil && IsEmbyRemoteID(mediaID) {
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
return err
}
return nil
}
if !played {
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
return err
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
dur := int64(0)
if IsEmbyRemoteID(mediaID) {
dur = remoteItemDurationMs(ctx, e, mediaID)
} else {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur = int64(m.DurationSec) * 1000
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
@@ -62,8 +60,46 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return err
}
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
return 0
}
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return 0
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
return 0
}
switch ticks := item["RunTimeTicks"].(type) {
case float64:
if ticks > 0 {
return int64(ticks) / 10_000
}
case int64:
if ticks > 0 {
return ticks / 10_000
}
case int:
if ticks > 0 {
return int64(ticks) / 10_000
}
}
return 0
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
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 +125,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,8 +142,33 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
return err
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
// 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 overlays the current MeBox user's locally recorded
// playback and favourite state onto remote Emby payloads. Remote mounts share
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil {
return nil
@@ -144,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
}
for _, item := range items {
id, _ := item["Id"].(string)
userData, _ := item["UserData"].(map[string]any)
if h := byMediaID[id]; h != nil {
item["UserData"] = mergedRemoteUserData(userData, h)
userData, _ = item["UserData"].(map[string]any)
}
if favSet[id] {
if userData == nil {
userData = map[string]any{}
item["UserData"] = userData
}
userData["IsFavorite"] = true
}
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
}
return nil
}
@@ -187,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
}
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
favorite := false
if existing, ok := raw.(map[string]any); ok {
if v, ok := existing["IsFavorite"].(bool); ok {
favorite = v
}
}
return applyMeBoxUserData(raw, history, favorite)
}
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok {
for key, value := range existing {
userData[key] = value
switch key {
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
continue
default:
userData[key] = value
}
}
}
userData["IsFavorite"] = favorite
if history == nil {
userData["PlaybackPositionTicks"] = int64(0)
userData["Played"] = false
userData["PlayedPercentage"] = float64(0)
userData["PlayCount"] = 0
return userData
}
duration := history.DurationMs
position := history.PositionMs
percentage := float64(0)
@@ -203,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage
if history.Completed {
playCount := 0
switch value := userData["PlayCount"].(type) {
case int:
playCount = value
case int64:
playCount = int(value)
case float64:
playCount = int(value)
}
if playCount < 1 {
userData["PlayCount"] = 1
}
userData["PlayCount"] = 1
} else {
userData["PlayCount"] = 0
}
return userData
}
+97 -4
View File
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
preserve any
}{
{
name: "in-progress preserves remote fields",
name: "in-progress preserves non-user remote fields only",
raw: map[string]any{
"PlayCount": 2,
"Custom": "remote-value",
"PlayCount": 2,
"IsFavorite": true,
"PlaybackPositionTicks": int64(999),
"Custom": "remote-value",
},
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000,
played: false,
percent: 25,
count: 2,
count: 0,
preserve: "remote-value",
},
{
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
}
if out["IsFavorite"] != false && out["IsFavorite"] != true {
t.Fatalf("IsFavorite missing: %#v", out)
}
})
}
}
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
out := applyMeBoxUserData(map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(42_000_000),
"Played": true,
"PlayedPercentage": 80.0,
"PlayCount": 3,
"Key": "keep",
}, nil, false)
if out["IsFavorite"] != false {
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
}
if out["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
}
if out["Played"] != false {
t.Fatalf("Played = %#v, want false", out["Played"])
}
if out["PlayedPercentage"] != float64(0) {
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
}
if out["PlayCount"] != 0 {
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
}
if out["Key"] != "keep" {
t.Fatalf("Key = %#v, want keep", out["Key"])
}
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
if fav["IsFavorite"] != true {
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
}
}
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
t.Fatalf("expected 95%% progress to be completed")
}
}
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
svc := newTestEmbyService(t)
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
t.Fatalf("create alice: %v", err)
}
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
t.Fatalf("create bob: %v", err)
}
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
t.Fatalf("mark played: %v", err)
}
var aliceRows, bobRows int64
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
if aliceRows != 1 {
t.Fatalf("alice history rows = %d, want 1", aliceRows)
}
if bobRows != 0 {
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
}
payload := map[string]any{
"Id": remoteID,
"UserData": map[string]any{
"IsFavorite": true,
"PlaybackPositionTicks": int64(50_000_000),
"Played": true,
},
}
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
t.Fatalf("merge for bob: %v", err)
}
bobData := payload["UserData"].(map[string]any)
if bobData["IsFavorite"] != false {
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
}
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
t.Fatalf("bob playback leaked: %#v", bobData)
}
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
t.Fatalf("merge for alice: %v", err)
}
aliceData := payload["UserData"].(map[string]any)
if aliceData["Played"] != true {
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
}
}
+5 -27
View File
@@ -10,21 +10,14 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// SyncUserFavorite keeps favourite state aligned across the local favourites table
// and the upstream remote Emby server for mounted items.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
// state is intentionally not proxied upstream.
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
if repo == nil || userID == "" || mediaID == "" {
return errors.New("missing favourite sync inputs")
}
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
return err
}
if favorite || IsEmbyRemoteID(mediaID) {
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
return err
}
}
return nil
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
}
// IsUserFavorite reports whether the user has favourited mediaID locally.
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
if remote == nil || !IsEmbyRemoteID(mediaID) {
return nil
}
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
if !ok {
return nil
}
_, acct, err := remote.ResolveMount(ctx, mountID)
if err != nil {
return err
}
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
}
+9 -5
View File
@@ -180,18 +180,20 @@ func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string,
var lastErr error
for i, u := range urls {
if i > 0 && log != nil {
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", redactSensitiveURL(u)))
}
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
lastErr = err
if log != nil {
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
log.Warn("ffmpeg 下载失败",
zap.String("url", redactSensitiveURL(u)),
zap.Error(redactSensitiveError(err)))
}
continue
}
return nil
}
return fmt.Errorf("所有下载源均失败:%v", lastErr)
return redactSensitiveError(fmt.Errorf("所有下载源均失败:%v", lastErr))
}
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
@@ -221,10 +223,12 @@ func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string)
return err
}
if n > 500<<20 {
return fmt.Errorf("归档文件过大(>500MB): %s", url)
return fmt.Errorf("归档文件过大(>500MB): %s", redactSensitiveURL(url))
}
if log != nil {
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
log.Info("ffmpeg 归档下载完成",
zap.String("url", redactSensitiveURL(url)),
zap.Int64("bytes", n))
}
return nil
}
+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)

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