mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec9adc976 | |||
| f4abdf78e2 | |||
| ae8db26c1e | |||
| 8017e994f4 | |||
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| c723664e46 | |||
| 5faa6975d0 | |||
| cbfae0252e | |||
| 57ee482146 | |||
| a9b3110b2c | |||
| ff86c2f597 | |||
| a5731fa963 | |||
| 531e45f677 | |||
| cd3f028f0b | |||
| bd7b7a722b | |||
| 83deffcf45 | |||
| c74f6be250 | |||
| c911a57467 | |||
| 1dbc3f8fe7 | |||
| a563ee11d6 | |||
| 215ebf6942 | |||
| e4c0e85eaa | |||
| 60f05129c9 | |||
| fa4c90d47a | |||
| 1ec8303fec | |||
| b28c0d9247 | |||
| 91c6d9dfd9 | |||
| 95d8126d65 | |||
| 3aa8662c45 | |||
| 5c85478883 | |||
| fd54d42db7 | |||
| 4595b7c5be | |||
| ee3927cdf1 | |||
| 4b34fc4616 | |||
| b7f1cd85f8 | |||
| be9d3a6d9e | |||
| b8e48a1f45 | |||
| 670dd90dbe | |||
| 9a68ef701f | |||
| 386fc358d1 | |||
| 2989649445 | |||
| 5ddca9a1f6 | |||
| e337d9932b | |||
| df654ea47b | |||
| 41fe75e136 | |||
| e65accf2cd |
@@ -0,0 +1,2 @@
|
||||
'Get-Content' is not recognized as an internal or external command,
|
||||
operable program or batch 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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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 ./
|
||||
|
||||
@@ -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、切换开机自启、查看日志、重启或退出。
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,24 +29,41 @@ var embyPlaceholderPNG = []byte{
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
req := c.Request.WithContext(ctx)
|
||||
if svc == nil || svc.Emby == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
// 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
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -128,6 +128,13 @@ func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container)
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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))
|
||||
|
||||
+166
-91
@@ -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,
|
||||
})
|
||||
@@ -563,6 +594,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return remoteItems
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
@@ -658,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()})
|
||||
@@ -666,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) {
|
||||
@@ -694,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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaGroupsSeriesBeforeLimit(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.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||
TMDbID: 503314,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||
}
|
||||
seenSeries := false
|
||||
seenMovie := false
|
||||
for _, item := range res.Items {
|
||||
switch item.TMDbID {
|
||||
case 61709:
|
||||
seenSeries = true
|
||||
if item.EpisodeNum != 1 {
|
||||
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||
}
|
||||
case 503314:
|
||||
seenMovie = true
|
||||
}
|
||||
}
|
||||
if !seenSeries || !seenMovie {
|
||||
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// GetPeople resolves adult cast/crew on demand from MetaTube. The provider and
|
||||
// remote movie ID are persisted in the existing external-ID columns; the
|
||||
// returned people are intentionally not written to the database.
|
||||
func (p *AdultProvider) GetPeople(ctx context.Context, media *model.Media) []map[string]any {
|
||||
if p == nil || media == nil || !media.NSFW {
|
||||
return nil
|
||||
}
|
||||
provider := strings.TrimSpace(media.TheTVDBID)
|
||||
movieID := strings.TrimSpace(media.DoubanID)
|
||||
if provider == "" || movieID == "" {
|
||||
return nil
|
||||
}
|
||||
match, err := p.GetMetaTubeCandidate(ctx, provider, movieID)
|
||||
if err != nil || match == nil {
|
||||
return nil
|
||||
}
|
||||
return match.People
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 云端播放不可用"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -83,6 +83,44 @@ func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType s
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
}
|
||||
|
||||
// PersonImageURL resolves a person avatar from either a disguised remote ID
|
||||
// or a person display name captured from a remote item's People field.
|
||||
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) {
|
||||
idOrName = strings.TrimSpace(idOrName)
|
||||
if idOrName == "" || e == nil {
|
||||
return "", nil
|
||||
}
|
||||
if IsEmbyRemoteID(idOrName) {
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil {
|
||||
profilePath := tmdbProfilePathFromTag(tag)
|
||||
if profilePath == "" {
|
||||
profilePath = e.cachedPersonImage(idOrName)
|
||||
}
|
||||
if profilePath != "" {
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
if e.remote != nil {
|
||||
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
|
||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||
|
||||
@@ -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,第一次见到
|
||||
@@ -61,6 +62,20 @@ type EmbyService struct {
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
|
||||
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
|
||||
personImages map[string]string
|
||||
}
|
||||
|
||||
// NewEmbyService is the constructor.
|
||||
@@ -76,6 +91,23 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
|
||||
return e
|
||||
}
|
||||
|
||||
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
|
||||
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.tmdb = tmdb
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// SetAdultProvider wires the on-demand adult-metadata provider used when a
|
||||
// detail request needs cast/crew not stored in the database.
|
||||
func (e *EmbyService) SetAdultProvider(adult *AdultProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.adult = adult
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
|
||||
if e != nil {
|
||||
e.cache = cache
|
||||
@@ -115,6 +147,8 @@ const (
|
||||
embyVirtualSeriesPrefix = "msgo-series-"
|
||||
embyVirtualSeasonPrefix = "msgo-season-"
|
||||
embyVirtualCacheTTL = 10 * time.Minute
|
||||
embyPeopleCacheTTL = 6 * time.Hour
|
||||
embyPeopleEmptyCacheTTL = 15 * time.Minute
|
||||
embyVisibilityCacheTTL = 30 * time.Second
|
||||
embySeriesGroupingLimit = maxMediaSearchLimit
|
||||
// Virtual artwork used to be wiped entirely once the in-memory map crossed
|
||||
@@ -143,6 +177,13 @@ type embyVisibilityCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
|
||||
// list refresh. TV clients commonly request the same posters/items repeatedly.
|
||||
type embyPeopleCacheEntry struct {
|
||||
people []map[string]any
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
|
||||
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
|
||||
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
|
||||
@@ -164,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 {
|
||||
|
||||
@@ -3,12 +3,24 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
|
||||
var cached embyCountsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return map[string]any{
|
||||
"MovieCount": cached.MovieCount,
|
||||
"SeriesCount": int(cached.SeriesCount),
|
||||
"EpisodeCount": cached.EpisodeCount,
|
||||
"ItemCount": cached.ItemCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
return e.applyUserMediaVisibility(ctx, q, userID)
|
||||
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
|
||||
MovieCount: movieCount,
|
||||
SeriesCount: int64(seriesCount),
|
||||
EpisodeCount: episodeCount,
|
||||
ItemCount: itemCount,
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return map[string]any{
|
||||
"MovieCount": movieCount,
|
||||
"SeriesCount": seriesCount,
|
||||
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
)
|
||||
|
||||
type embyItemsCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyLatestCacheValue struct {
|
||||
@@ -19,6 +20,13 @@ type embyLatestCacheValue struct {
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyCountsCacheValue struct {
|
||||
MovieCount int64 `json:"movie_count"`
|
||||
SeriesCount int64 `json:"series_count"`
|
||||
EpisodeCount int64 `json:"episode_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
includeTypes := append([]string(nil), p.IncludeItemTypes...)
|
||||
filters := append([]string(nil), p.Filters...)
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -35,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 {
|
||||
@@ -62,14 +57,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seasonPayload(season), nil
|
||||
item := e.seasonPayload(season)
|
||||
if media := seriesPeopleMedia(season.Series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
@@ -80,7 +83,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -103,47 +110,127 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
}
|
||||
}
|
||||
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
|
||||
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))
|
||||
}
|
||||
@@ -156,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 {
|
||||
@@ -164,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) {
|
||||
@@ -180,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)
|
||||
@@ -195,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})
|
||||
@@ -256,7 +359,7 @@ func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[str
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0))
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0, false))
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
||||
@@ -355,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
|
||||
@@ -379,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 {
|
||||
@@ -387,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))
|
||||
}
|
||||
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)
|
||||
@@ -427,17 +585,70 @@ 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
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
||||
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
|
||||
parentID := m.LibraryID
|
||||
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)
|
||||
@@ -482,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,
|
||||
@@ -494,13 +705,13 @@ 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,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"Genres": splitCSV(m.Genres),
|
||||
"People": e.resolveMediaPeople(ctx, m),
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -531,10 +742,90 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
if includePeople {
|
||||
item["People"] = e.resolveMediaPeople(ctx, m)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if m == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
cacheKey := embyMediaPeopleCacheKey(m)
|
||||
if cacheKey == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
if people, ok := e.cachedMediaPeople(cacheKey); ok {
|
||||
return people
|
||||
}
|
||||
people := e.fetchTMDbPeople(ctx, m)
|
||||
if len(people) == 0 {
|
||||
people = e.resolveNFOMediaPeople(m)
|
||||
}
|
||||
e.rememberMediaPeople(cacheKey, people)
|
||||
return people
|
||||
}
|
||||
|
||||
func seriesPeopleMedia(group embySeriesGroup) *model.Media {
|
||||
if group.TMDbID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if len(group.Episodes) > 0 {
|
||||
media := group.Episodes[0]
|
||||
media.TMDbID = group.TMDbID
|
||||
return &media
|
||||
}
|
||||
return &model.Media{Title: group.Name, TMDbID: group.TMDbID, SeasonNum: 1}
|
||||
}
|
||||
|
||||
func (e *EmbyService) fetchTMDbPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if e == nil || m == nil {
|
||||
return nil
|
||||
}
|
||||
var people []map[string]any
|
||||
if e.tmdb != nil && m.TMDbID > 0 {
|
||||
mediaType := "movie"
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
mediaType = "tv"
|
||||
}
|
||||
people, err := e.tmdb.GetPeople(ctx, m.TMDbID, mediaType)
|
||||
if err != nil {
|
||||
if e.log != nil {
|
||||
e.log.Debug("tmdb: detail people lookup failed", zap.Int("tmdb_id", m.TMDbID), zap.String("type", mediaType), zap.Error(err))
|
||||
}
|
||||
} else if len(people) > 0 {
|
||||
e.rememberPersonImages(people)
|
||||
return people
|
||||
}
|
||||
}
|
||||
if m.NSFW && e.adult != nil {
|
||||
people = e.adult.GetPeople(ctx, m)
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func embyMediaPeopleCacheKey(m *model.Media) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
mediaType := "movie"
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
mediaType = "tv"
|
||||
}
|
||||
return fmt.Sprintf("tmdb:%d:%s", m.TMDbID, mediaType)
|
||||
}
|
||||
if strings.TrimSpace(m.ID) != "" {
|
||||
return "media:" + strings.TrimSpace(m.ID)
|
||||
}
|
||||
if strings.TrimSpace(m.Path) != "" {
|
||||
return "path:" + strings.ToLower(filepath.Clean(m.Path))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveNFOMediaPeople(m *model.Media) []map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
@@ -563,7 +854,6 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
|
||||
people := make([]map[string]any, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range candidates {
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
doc, _, err := decodeNFOFile(p)
|
||||
@@ -613,6 +903,89 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
return people
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
e.peopleMu.RLock()
|
||||
entry, ok := e.peopleCache[key]
|
||||
e.peopleMu.RUnlock()
|
||||
if !ok || now.After(entry.expiresAt) {
|
||||
if ok {
|
||||
e.peopleMu.Lock()
|
||||
delete(e.peopleCache, key)
|
||||
e.peopleMu.Unlock()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, len(entry.people))
|
||||
copy(out, entry.people)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
e.peopleMu.Lock()
|
||||
defer e.peopleMu.Unlock()
|
||||
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
|
||||
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
|
||||
}
|
||||
stored := make([]map[string]any, len(people))
|
||||
copy(stored, people)
|
||||
ttl := embyPeopleCacheTTL
|
||||
if len(stored) == 0 {
|
||||
ttl = embyPeopleEmptyCacheTTL
|
||||
}
|
||||
e.peopleCache[key] = embyPeopleCacheEntry{
|
||||
people: stored,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
|
||||
if e == nil || len(people) == 0 {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
defer e.personImageMu.Unlock()
|
||||
if e.personImages == nil || len(e.personImages) > 20000 {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
for _, person := range people {
|
||||
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
|
||||
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
|
||||
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
|
||||
e.personImages[id] = profilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedPersonImage(id string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
e.personImageMu.RLock()
|
||||
profilePath := e.personImages[strings.TrimSpace(id)]
|
||||
e.personImageMu.RUnlock()
|
||||
return profilePath
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
|
||||
profilePath = strings.TrimSpace(profilePath)
|
||||
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
if e.personImages == nil {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
e.personImages[strings.TrimSpace(id)] = profilePath
|
||||
e.personImageMu.Unlock()
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
orderIncludesDirection = false
|
||||
case "premieredate", "productionyear":
|
||||
order = mediaReleaseOrderSQL(desc)
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "dateplayed":
|
||||
order = "resume.watched_at"
|
||||
orderIncludesDirection = false
|
||||
@@ -181,7 +181,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if libraryID != "" {
|
||||
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
|
||||
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(pageGroups))
|
||||
for _, group := range pageGroups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
|
||||
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
|
||||
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
|
||||
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
|
||||
apply := func(q *gorm.DB) *gorm.DB {
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
if err := movieQ.Find(&movieRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
|
||||
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
|
||||
// 查询并把响应时间浪费在用户根本看不到的条目上。
|
||||
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
|
||||
|
||||
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
|
||||
type entry struct {
|
||||
sortAt time.Time
|
||||
payload map[string]any
|
||||
sortAt time.Time
|
||||
media *model.Media
|
||||
group *embySeriesGroup
|
||||
}
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
||||
for _, g := range seriesGroups {
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
|
||||
for i := range seriesGroups {
|
||||
group := &seriesGroups[i]
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
|
||||
}
|
||||
for _, item := range movieItems {
|
||||
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
|
||||
for i := range movieRows {
|
||||
media := &movieRows[i]
|
||||
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].sortAt.After(entries[j].sortAt)
|
||||
})
|
||||
total := len(entries)
|
||||
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
|
||||
pageMovies := make([]model.Media, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
items = append(items, en.payload)
|
||||
if en.media != nil {
|
||||
pageMovies = append(pageMovies, *en.media)
|
||||
}
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadByID := make(map[string]map[string]any, len(moviePayloads))
|
||||
for _, item := range moviePayloads {
|
||||
if id, ok := item["Id"].(string); ok {
|
||||
payloadByID[id] = item
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
pageGroups := make([]embySeriesGroup, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
switch {
|
||||
case en.group != nil:
|
||||
pageGroups = append(pageGroups, *en.group)
|
||||
items = append(items, e.seriesPayload(*en.group))
|
||||
case en.media != nil:
|
||||
if item := payloadByID[en.media.ID]; item != nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -55,3 +62,48 @@ func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
|
||||
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/movie/11/credits" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"cast": []map[string]any{
|
||||
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := newTestEmbyService(t)
|
||||
svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{
|
||||
TMDbAPIKey: "test-key",
|
||||
TMDbAPIProxy: server.URL,
|
||||
TMDbImageProxy: "https://image.example/t/p",
|
||||
}}, zap.NewNop(), nil))
|
||||
media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), media)
|
||||
peopleAgain := svc.resolveMediaPeople(t.Context(), media)
|
||||
if len(people) != 1 || len(peopleAgain) != 1 {
|
||||
t.Fatalf("people=%#v again=%#v", people, peopleAgain)
|
||||
}
|
||||
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?p2" {
|
||||
t.Fatalf("unexpected person: %#v", people[0])
|
||||
}
|
||||
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw != "https://image.example/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("person image URL=%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+286
-10
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
|
||||
http *http.Client
|
||||
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
||||
cache *RuntimeCacheService
|
||||
|
||||
personMu sync.RWMutex
|
||||
personImages map[string]embyRemotePersonImageRef
|
||||
}
|
||||
|
||||
type embyRemotePersonImageRef struct {
|
||||
accountID string
|
||||
remoteID string
|
||||
}
|
||||
|
||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||
@@ -502,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"`
|
||||
@@ -591,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
|
||||
}
|
||||
@@ -621,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,难以定位;这里显式报错。
|
||||
@@ -648,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
|
||||
@@ -797,16 +813,30 @@ 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
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.rememberRemotePeople(mount, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
if cacheKey != "" && len(out) > 0 {
|
||||
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -830,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 反代)。
|
||||
@@ -959,6 +1163,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
|
||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||
}
|
||||
|
||||
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
|
||||
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
|
||||
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
|
||||
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
|
||||
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
|
||||
return
|
||||
}
|
||||
people := remotePeopleMaps(payload["People"])
|
||||
if len(people) == 0 {
|
||||
return
|
||||
}
|
||||
r.personMu.Lock()
|
||||
defer r.personMu.Unlock()
|
||||
if r.personImages == nil || len(r.personImages) > 20000 {
|
||||
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
|
||||
}
|
||||
for _, person := range people {
|
||||
name := strings.TrimSpace(remoteItemString(person, "Name"))
|
||||
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
|
||||
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
|
||||
continue
|
||||
}
|
||||
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
|
||||
accountID: mount.AccountID,
|
||||
remoteID: remoteID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
|
||||
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
r.personMu.RLock()
|
||||
ref, ok := r.personImages[key]
|
||||
r.personMu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
acct := r.AccountByID(ctx, ref.accountID)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
func remotePeopleMaps(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if person, ok := item.(map[string]any); ok {
|
||||
out = append(out, person)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||
@@ -1027,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 != "" {
|
||||
@@ -1187,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
|
||||
}
|
||||
|
||||
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
|
||||
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
|
||||
if people, ok := m["People"]; ok {
|
||||
RewriteEmbyRemoteIDs(people, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
"Items": []any{
|
||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||
},
|
||||
"People": []any{
|
||||
map[string]any{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||
t.Fatalf("nested Id = %v", nested["Id"])
|
||||
}
|
||||
person := payload["People"].([]any)[0].(map[string]any)
|
||||
if person["Id"] != "embyremote~acct-1~person-1" {
|
||||
t.Fatalf("person Id = %v", person["Id"])
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
|
||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Id": "item-1",
|
||||
"Name": "测试电影",
|
||||
"People": []map[string]any{
|
||||
{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-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": "user-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-people"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
|
||||
|
||||
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "People") {
|
||||
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
|
||||
}
|
||||
people, ok := out["People"].([]any)
|
||||
if !ok || len(people) != 1 {
|
||||
t.Fatalf("People = %#v, want one person", out["People"])
|
||||
}
|
||||
person := people[0].(map[string]any)
|
||||
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
|
||||
t.Fatalf("person Id = %v, want encoded remote id", got)
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
|
||||
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
|
||||
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user