Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad07f7add | |||
| cb539afd2a | |||
| 594166f696 | |||
| e0b07492a5 | |||
| 3be6978e70 | |||
| 065652d758 | |||
| e273b5e87d | |||
| c9c612df1b | |||
| e2cd32af53 | |||
| 28485ed429 | |||
| 34ccf14cda | |||
| 44fde6c5a0 | |||
| 94ef0116b1 | |||
| 016c6687e9 | |||
| b5ce4646cf | |||
| 47899284a5 | |||
| c24ebe181d | |||
| 9b2b592003 | |||
| 0c4668c156 | |||
| 474513f18a | |||
| 0810bfb234 | |||
| 61a7260eaf | |||
| 45f4763b0c | |||
| f4f57759ce | |||
| 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 | |||
| f54ef2228c | |||
| 3c2bfb14ca | |||
| 8f64f961ac | |||
| 6797394c5b | |||
| e71d9d61d1 | |||
| 1ff60d5641 | |||
| 3f361631d0 | |||
| cfe0c0cf28 | |||
| d6355d5582 | |||
| 0f06bdf929 | |||
| e840cbd2c9 | |||
| 2bb3610da7 | |||
| 9ca1b28cb4 |
@@ -0,0 +1,2 @@
|
|||||||
|
'Get-Content' is not recognized as an internal or external command,
|
||||||
|
operable program or batch file.
|
||||||
@@ -14,6 +14,7 @@ verify-cache/
|
|||||||
verify-media/
|
verify-media/
|
||||||
verify-downloads/
|
verify-downloads/
|
||||||
.codex-*
|
.codex-*
|
||||||
|
.codex/
|
||||||
.tmp/
|
.tmp/
|
||||||
.tmp_*
|
.tmp_*
|
||||||
.tmp-deploy-*
|
.tmp-deploy-*
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Install
|
- name: Install
|
||||||
@@ -196,8 +196,12 @@ jobs:
|
|||||||
path: web/dist
|
path: web/dist
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: |
|
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 }} \
|
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
|
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||||
- name: Package
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
# before the Go toolchain touches the web package.
|
# before the Go toolchain touches the web package.
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build SPA
|
- name: Build SPA
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
- name: Build linux/arm64
|
- 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
|
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
|
- 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -130,4 +130,4 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.version.outputs.full_version }}
|
VERSION=${{ steps.version.outputs.full_version }}
|
||||||
cache-from: type=gha
|
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.
|
# before the Go toolchain touches the `web` package.
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build SPA
|
- name: Build SPA
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ config.yaml
|
|||||||
.tmp-live-backups/
|
.tmp-live-backups/
|
||||||
.tmp-*
|
.tmp-*
|
||||||
.codex-*
|
.codex-*
|
||||||
|
.codex/
|
||||||
downloads/
|
downloads/
|
||||||
media/
|
media/
|
||||||
*.pid
|
*.pid
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Multi-architecture build for MeBox.
|
# 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 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||||
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||||
#
|
#
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ---- Stage 1: frontend (always build on the host architecture) -------------
|
# ---- 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/
|
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
|
||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
COPY web/package*.json ./
|
COPY web/package*.json ./
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ environment:
|
|||||||
**硬链接失败(cross-device link)?**
|
**硬链接失败(cross-device link)?**
|
||||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 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 头。
|
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
|
||||||
|
|
||||||
@@ -207,6 +212,7 @@ environment:
|
|||||||
## 开发构建
|
## 开发构建
|
||||||
|
|
||||||
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
||||||
|
前端构建要求 Node.js `20.19+` 或 `22.12+`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix web ci
|
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 单文件可执行程序。
|
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.
|
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
|
## 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")
|
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()
|
_ = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,21 +11,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strings"
|
"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=...".
|
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||||
@@ -46,95 +33,5 @@ func effectiveVersion(buildVersion string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
runProgram()
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
|||||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
|||||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
|||||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
|
||||||
|
fontResp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(fontResp, fontReq)
|
||||||
|
if fontResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("font status = %d, want 200", fontResp.Code)
|
||||||
|
}
|
||||||
|
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
|
||||||
|
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
|
||||||
|
}
|
||||||
|
if got := fontResp.Body.String(); got != "wOF2-test-font" {
|
||||||
|
t.Fatalf("font body = %q, want wOF2-test-font", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||||
|
missingFontResp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(missingFontResp, missingFontReq)
|
||||||
|
if missingFontResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(missingFontResp.Body.String(), "index") {
|
||||||
|
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
||||||
brandResp := httptest.NewRecorder()
|
brandResp := httptest.NewRecorder()
|
||||||
router.ServeHTTP(brandResp, brandReq)
|
router.ServeHTTP(brandResp, brandReq)
|
||||||
|
|||||||
@@ -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"
|
"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 {
|
if !cfg.App.Debug {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
r.Use(middleware.RequestLogger(logger))
|
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 {
|
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.")
|
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
|||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||||
|
fonts := r.Group("/fonts")
|
||||||
|
fonts.Use(middleware.GzipStatic())
|
||||||
|
fonts.Use(func(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
|
||||||
brand := r.Group("/brand")
|
brand := r.Group("/brand")
|
||||||
brand.Use(func(c *gin.Context) {
|
brand.Use(func(c *gin.Context) {
|
||||||
setNoCacheHeaders(c)
|
setNoCacheHeaders(c)
|
||||||
@@ -70,11 +80,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
|||||||
r.GET(rootFile, serveFSFile(root, name))
|
r.GET(rootFile, serveFSFile(root, name))
|
||||||
r.HEAD(rootFile, serveFSFile(root, name))
|
r.HEAD(rootFile, serveFSFile(root, name))
|
||||||
}
|
}
|
||||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
if shouldBypassSPAFallback(path) {
|
if shouldBypassSPAFallback(path) {
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
|
||||||
|
#
|
||||||
|
# 组件:
|
||||||
|
# MeBox + PostgreSQL + Redis + MetaTube
|
||||||
|
#
|
||||||
|
# 与 docker-compose.standard.yml 的区别:
|
||||||
|
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
|
||||||
|
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
|
||||||
|
#
|
||||||
|
# 使用方式二选一:
|
||||||
|
# 1. 保存为 docker-compose.yml 后执行:
|
||||||
|
# docker compose up -d
|
||||||
|
# 2. 保留本文件名时执行:
|
||||||
|
# docker compose -f docker-compose.metatube.yml up -d
|
||||||
|
#
|
||||||
|
# 首次启动后需要做一次配置(只有这一处是手动的):
|
||||||
|
# 登录 MeBox → 设置 → 番号刮削:
|
||||||
|
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
|
||||||
|
# - MetaTube 服务端地址:http://metatube:8080
|
||||||
|
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
|
||||||
|
#
|
||||||
|
# 说明:
|
||||||
|
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
|
||||||
|
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
|
||||||
|
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
|
||||||
|
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
|
||||||
|
# - 更新全部组件:
|
||||||
|
# docker compose -f docker-compose.metatube.yml pull
|
||||||
|
# docker compose -f docker-compose.metatube.yml up -d
|
||||||
|
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
|
||||||
|
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
|
||||||
|
#
|
||||||
|
# 默认账号:
|
||||||
|
# admin / admin123
|
||||||
|
|
||||||
|
services:
|
||||||
|
mebox:
|
||||||
|
image: ghcr.io/truewhile/mebox:latest
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "18080:8080"
|
||||||
|
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||||
|
- ./data:/data
|
||||||
|
|
||||||
|
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||||
|
- ./cache:/cache
|
||||||
|
|
||||||
|
# 媒体库目录。自动整理/重命名/入库需要读写权限。
|
||||||
|
# NAS 示例:source: /vol1/1000/Media
|
||||||
|
# Windows Docker Desktop 示例:source: D:/Media
|
||||||
|
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||||
|
- ./media: /media
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
|
PUID: "0"
|
||||||
|
PGID: "0"
|
||||||
|
|
||||||
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
|
MEBOX_APP_PORT: 8080
|
||||||
|
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||||
|
MEBOX_APP_DATA_DIR: /data
|
||||||
|
MEBOX_LOGGING_LEVEL: info
|
||||||
|
MEBOX_LOGGING_FORMAT: console
|
||||||
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
|
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||||
|
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||||
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
|
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||||
|
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||||
|
|
||||||
|
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
||||||
|
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
|
||||||
|
MEBOX_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
|
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||||
|
|
||||||
|
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||||
|
MEBOX_MEDIA_DIR: /media
|
||||||
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
|
MEBOX_DOWNLOAD_DIR: /downloads
|
||||||
|
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||||
|
|
||||||
|
MEBOX_TRANSCODER_ENABLED: "true"
|
||||||
|
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||||
|
MEBOX_TRANSCODER_REALTIME: "true"
|
||||||
|
MEBOX_TRANSCODER_THREADS: "2"
|
||||||
|
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||||
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||||
|
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
|
||||||
|
pull_policy: missing
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mebox
|
||||||
|
POSTGRES_USER: mebox
|
||||||
|
POSTGRES_PASSWORD: mebox
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
volumes:
|
||||||
|
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# 一次性容器:确保 metatube 库存在。
|
||||||
|
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
|
||||||
|
# 数据库已存在时它会直接退出,重复执行无副作用。
|
||||||
|
metatube-db-init:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
pull_policy: missing
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: mebox
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "1"
|
||||||
|
|
||||||
|
metatube:
|
||||||
|
image: ghcr.io/metatube-community/metatube-server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
metatube-db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
|
||||||
|
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
|
||||||
|
command:
|
||||||
|
- -dsn
|
||||||
|
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
|
||||||
|
- -port
|
||||||
|
- "8080"
|
||||||
|
- -db-auto-migrate
|
||||||
|
- -db-prepared-stmt
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
pull_policy: missing
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
- --maxmemory
|
||||||
|
- 256mb
|
||||||
|
- --maxmemory-policy
|
||||||
|
- allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- ./redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
@@ -81,8 +81,8 @@ services:
|
|||||||
MEBOX_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MEBOX_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
MEBOX_DATABASE_TYPE: postgres
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
@@ -124,8 +124,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -147,8 +147,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -172,8 +172,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
opensearch:
|
opensearch:
|
||||||
image: opensearchproject/opensearch:2
|
image: opensearchproject/opensearch:2
|
||||||
@@ -196,5 +196,5 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ services:
|
|||||||
MEBOX_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MEBOX_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -82,5 +82,5 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|||||||
@@ -44,30 +44,13 @@ services:
|
|||||||
# NAS 示例:source: /vol1/1000/Media
|
# NAS 示例:source: /vol1/1000/Media
|
||||||
# Windows Docker Desktop 示例:source: D:/Media
|
# Windows Docker Desktop 示例:source: D:/Media
|
||||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||||
- type: bind
|
- ./media: /media
|
||||||
source: ./media
|
|
||||||
target: /media
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
|
|
||||||
# 下载目录。需要和下载器保存路径保持一致。
|
|
||||||
# NAS 示例:source: /vol1/1000/Downloads
|
|
||||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
|
||||||
- type: bind
|
|
||||||
source: ./downloads
|
|
||||||
target: /downloads
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
|
|
||||||
# 管理面板「系统更新」需要访问 Docker 引擎。
|
|
||||||
# 需要一键更新 Docker 镜像时取消下一行注释。
|
|
||||||
# - /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
PUID: "1000"
|
PUID: "0"
|
||||||
PGID: "1000"
|
PGID: "0"
|
||||||
|
|
||||||
MEBOX_APP_HOST: 0.0.0.0
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
MEBOX_APP_PORT: 8080
|
MEBOX_APP_PORT: 8080
|
||||||
@@ -76,8 +59,8 @@ services:
|
|||||||
MEBOX_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MEBOX_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
MEBOX_DATABASE_TYPE: postgres
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
@@ -113,8 +96,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -136,8 +119,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -161,5 +144,5 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ services:
|
|||||||
MEBOX_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MEBOX_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
||||||
@@ -141,8 +141,8 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -166,5 +166,5 @@ services:
|
|||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "3"
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
|
|
||||||
|
|
||||||
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 写在前面
|
|
||||||
|
|
||||||
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
|
|
||||||
|
|
||||||
GitHub:https://github.com/truewhile/MeBox
|
|
||||||
|
|
||||||
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
|
|
||||||
|
|
||||||
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 它能解决什么问题?
|
|
||||||
|
|
||||||
家里看电影电视的痛点,MeBox 基本一把梭:
|
|
||||||
|
|
||||||
| 痛点 | MeBox 的解法 |
|
|
||||||
| --- | --- |
|
|
||||||
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
|
|
||||||
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
|
|
||||||
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
|
|
||||||
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
|
|
||||||
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特点一览
|
|
||||||
|
|
||||||
**1. 现代化 Web UI,海报墙开箱即用**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
|
|
||||||
|
|
||||||
**2. 媒体库与刮削**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
|
|
||||||
|
|
||||||
**3. 详情页与多季管理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
|
|
||||||
|
|
||||||
**4. Emby/Jellyfin 客户端无缝兼容**
|
|
||||||
|
|
||||||
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
|
|
||||||
|
|
||||||
**5. 网页播放器 + 弹幕自动匹配**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
|
|
||||||
|
|
||||||
**6. 网盘 STRM:网盘当本地盘用**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
|
|
||||||
|
|
||||||
**7. 远程 Emby 挂载(特色功能)**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
|
|
||||||
|
|
||||||
**8. 任务队列统一管理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
|
|
||||||
|
|
||||||
**9. 下载与自动整理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
|
||||||
|
|
||||||
**10. 多用户与权限**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
|
|
||||||
|
|
||||||
**11. 运维省心**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 使用教程:从零到海报墙只要 5 步
|
|
||||||
|
|
||||||
### 第 1 步:Docker 一键部署
|
|
||||||
|
|
||||||
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p MeBox && cd MeBox
|
|
||||||
|
|
||||||
# 最省心:单镜像 + 内置 SQLite
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
|
|
||||||
|
|
||||||
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
|
|
||||||
|
|
||||||
### 第 2 步:登录并修改密码
|
|
||||||
|
|
||||||
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
|
|
||||||
|
|
||||||
### 第 3 步:创建媒体库 + 扫库
|
|
||||||
|
|
||||||
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
|
|
||||||
|
|
||||||
### 第 4 步:配置元数据刮削
|
|
||||||
|
|
||||||
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
|
|
||||||
|
|
||||||
### 第 5 步(可选但强烈推荐):
|
|
||||||
|
|
||||||
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
|
||||||
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
|
||||||
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
|
||||||
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
|
||||||
|
|
||||||
### 路径映射小抄(Docker 最常见坑)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
|
|
||||||
environment:
|
|
||||||
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
|
||||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
|
||||||
```
|
|
||||||
|
|
||||||
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署档位怎么选?
|
|
||||||
|
|
||||||
| 档位 | 文件 | 组件 | 适合 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
|
|
||||||
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
|
|
||||||
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
|
|
||||||
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术栈与致谢
|
|
||||||
|
|
||||||
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
|
|
||||||
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
|
||||||
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
|
|
||||||
|
|
||||||
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 链接
|
|
||||||
|
|
||||||
- GitHub:https://github.com/truewhile/MeBox
|
|
||||||
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
|
|
||||||
- License:GPL-3.0
|
|
||||||
|
|
||||||
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Segment Coverage Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
|
||||||
|
|
||||||
|
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Negative cache TTL + ledgerFresh tests
|
||||||
|
|
||||||
|
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
|
||||||
|
|
||||||
|
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
|
||||||
|
|
||||||
|
### Task 2: Playback merge + propagation
|
||||||
|
|
||||||
|
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
|
||||||
|
|
||||||
|
- Constant `PropagatedSource = "propagated"`.
|
||||||
|
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
|
||||||
|
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
|
||||||
|
|
||||||
|
### Task 3: Prewarm API + scheduler job
|
||||||
|
|
||||||
|
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
|
||||||
|
|
||||||
|
- `Prewarm(ctx, limit)` rate-limited refreshes.
|
||||||
|
- Job `segment_prewarm` every 6h.
|
||||||
|
|
||||||
|
### Task 4: Verify
|
||||||
|
|
||||||
|
- `go test` for affected packages.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Segment Coverage Improvements (1–4, option C)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
|
||||||
|
|
||||||
|
## Scope (approved)
|
||||||
|
|
||||||
|
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
|
||||||
|
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
|
||||||
|
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
|
||||||
|
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### Prewarm (`segment_prewarm`)
|
||||||
|
|
||||||
|
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
|
||||||
|
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
|
||||||
|
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
|
||||||
|
- Order: recently played first, then others.
|
||||||
|
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
|
||||||
|
- Reuse `MediaSegmentService` refresh + propagation.
|
||||||
|
- Manual `RunNow` can bypass the toggle (same pattern as organize).
|
||||||
|
|
||||||
|
### Propagation
|
||||||
|
|
||||||
|
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
|
||||||
|
- Source tag: `propagated`.
|
||||||
|
- Do not propagate credits/preview/recap.
|
||||||
|
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
|
||||||
|
|
||||||
|
### Negative cache
|
||||||
|
|
||||||
|
- `segmentMissingTTL`: 24h (was 7d).
|
||||||
|
- `segmentFoundTTL`: 30d (unchanged).
|
||||||
|
- Provider errors (non-404) continue to skip ledger writes.
|
||||||
|
|
||||||
|
### Duration (C)
|
||||||
|
|
||||||
|
- No new batch probe job.
|
||||||
|
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Prewarm increases `media_segment_fetches` over time without blocking play.
|
||||||
|
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
|
||||||
|
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
|
||||||
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 793 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -3,6 +3,7 @@ module github.com/truewhile/MeBox
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gin-contrib/gzip v1.2.6
|
github.com/gin-contrib/gzip v1.2.6
|
||||||
@@ -12,15 +13,16 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/metatube-community/metatube-sdk-go v1.4.0
|
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/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.11.1
|
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
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/image v0.37.0
|
golang.org/x/image v0.45.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sync v0.22.0
|
||||||
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
@@ -47,10 +49,11 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile 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/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/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -68,7 +71,7 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
@@ -88,8 +91,7 @@ require (
|
|||||||
golang.org/x/arch v0.25.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
modernc.org/libc v1.70.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 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
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/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
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.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.1/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.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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=
|
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/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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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.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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
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 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
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=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
if cfg.Cache.MediaTTLSeconds != 90 {
|
if cfg.Cache.MediaTTLSeconds != 90 {
|
||||||
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
|
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" {
|
if cfg.Search.Index != "mebox_media" {
|
||||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
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_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
|
||||||
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
|
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
|
||||||
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
|
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_BACKEND", "opensearch")
|
||||||
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
||||||
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
|
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 == "" {
|
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)
|
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 {
|
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", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
|
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" {
|
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)
|
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
|
defaultDatabaseMaxIdleConns = 4
|
||||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||||
|
DefaultCacheMemoryMaxSizeMB = 128
|
||||||
)
|
)
|
||||||
|
|
||||||
func setDefaults(v *viper.Viper) {
|
func setDefaults(v *viper.Viper) {
|
||||||
@@ -43,11 +44,14 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("logging.max_backups", 10)
|
v.SetDefault("logging.max_backups", 10)
|
||||||
|
|
||||||
v.SetDefault("cache.cache_dir", "./cache")
|
v.SetDefault("cache.cache_dir", "./cache")
|
||||||
v.SetDefault("cache.images_max_size_mb", 500)
|
v.SetDefault("cache.images_max_size_mb", 2000)
|
||||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
v.SetDefault("cache.images_originals_max_size_mb", 256)
|
||||||
|
v.SetDefault("cache.images_originals_ttl_hours", 168)
|
||||||
|
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||||
v.SetDefault("cache.redis_url", "")
|
v.SetDefault("cache.redis_url", "")
|
||||||
v.SetDefault("cache.redis_prefix", "mebox")
|
v.SetDefault("cache.redis_prefix", "mebox")
|
||||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||||
|
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||||
|
|
||||||
v.SetDefault("search.backend", "")
|
v.SetDefault("search.backend", "")
|
||||||
v.SetDefault("search.opensearch_url", "")
|
v.SetDefault("search.opensearch_url", "")
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ func (c *Config) normalize() error {
|
|||||||
if c.Cache.ImagesMaxSizeMB < 0 {
|
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||||
c.Cache.ImagesMaxSizeMB = 0
|
c.Cache.ImagesMaxSizeMB = 0
|
||||||
}
|
}
|
||||||
|
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
|
||||||
|
c.Cache.ImagesOriginalsMaxSizeMB = 0
|
||||||
|
}
|
||||||
|
if c.Cache.ImagesOriginalsTTLHours < 0 {
|
||||||
|
c.Cache.ImagesOriginalsTTLHours = 0
|
||||||
|
}
|
||||||
|
if c.Cache.MemoryMaxSizeMB <= 0 {
|
||||||
|
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
|
||||||
|
}
|
||||||
if c.Cache.RedisPrefix == "" {
|
if c.Cache.RedisPrefix == "" {
|
||||||
c.Cache.RedisPrefix = "mebox"
|
c.Cache.RedisPrefix = "mebox"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,17 +113,26 @@ type LoggingConfig struct {
|
|||||||
MaxBackups int `mapstructure:"max_backups"`
|
MaxBackups int `mapstructure:"max_backups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
CacheDir string `mapstructure:"cache_dir"`
|
CacheDir string `mapstructure:"cache_dir"`
|
||||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
|
||||||
TTLHours int `mapstructure:"ttl_hours"`
|
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
|
||||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
|
||||||
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
|
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
|
||||||
RedisURL string `mapstructure:"redis_url"`
|
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
|
||||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
|
||||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
|
||||||
|
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
|
||||||
|
RedisURL string `mapstructure:"redis_url"`
|
||||||
|
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||||
|
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||||
|
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||||
|
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||||
|
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||||
|
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||||
|
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchConfig struct {
|
type SearchConfig struct {
|
||||||
|
|||||||
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
|
|||||||
if z.log == nil {
|
if z.log == nil {
|
||||||
return
|
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_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_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_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_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_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`,
|
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ type settingReq struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
|
||||||
|
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
|
||||||
|
var maskedSettingKeys = map[string]bool{
|
||||||
|
service.SettingTelegramBotToken: true,
|
||||||
|
}
|
||||||
|
|
||||||
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
||||||
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range settings {
|
||||||
|
if maskedSettingKeys[settings[i].Key] {
|
||||||
|
settings[i].Value = service.MaskSecret(settings[i].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, settings)
|
c.JSON(http.StatusOK, settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
|
||||||
|
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
|
||||||
|
func isMaskedSettingValue(value string) bool {
|
||||||
|
return strings.Contains(value, "***")
|
||||||
|
}
|
||||||
|
|
||||||
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req settingReq
|
var req settingReq
|
||||||
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
|
||||||
|
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
oldValue := ""
|
oldValue := ""
|
||||||
if req.Key == service.AdultLibraryIDsSettingKey {
|
if req.Key == service.AdultLibraryIDsSettingKey {
|
||||||
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
||||||
@@ -65,8 +87,13 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||||
svc.Transcoder.StopAll()
|
svc.Transcoder.StopAll()
|
||||||
}
|
}
|
||||||
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
|
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
|
||||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
if 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)
|
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()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -14,7 +16,11 @@ import (
|
|||||||
// specific danmaku library chosen by the user after a disambiguation.
|
// specific danmaku library chosen by the user after a disambiguation.
|
||||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
uid := currentUserID(c)
|
||||||
|
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
|
||||||
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -23,11 +29,36 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
|
||||||
// size, area, enabled) so the player can initialize its control panel without
|
// preferences so the player can initialize without admin privileges.
|
||||||
// admin privileges.
|
|
||||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
|
||||||
|
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := currentUserID(c)
|
||||||
|
if strings.TrimSpace(uid) == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.DanmakuSettingsPatch
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||||
|
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
if userID != "" {
|
||||||
|
c.Set(middleware.CtxUserID, userID)
|
||||||
|
}
|
||||||
|
return c, w
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDanmakuSettingsService(t *testing.T) *service.Container {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||||
|
user.ID = "user-1"
|
||||||
|
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
return &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"merge_sources":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 resp struct {
|
||||||
|
MergeSources bool `json:"merge_sources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.MergeSources {
|
||||||
|
t.Fatal("response should echo merge_sources=true")
|
||||||
|
}
|
||||||
|
// 落库校验:重新读取应为 true。
|
||||||
|
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||||
|
t.Fatal("merge preference was not persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"merge_sources":true}`, "")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||||
|
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||||
|
getDanmakuConfigHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var cfg struct {
|
||||||
|
MergeSources bool `json:"merge_sources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.MergeSources {
|
||||||
|
t.Fatal("default merge preference should be false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||||
|
getDanmakuConfigHandler(svc)(c2)
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.MergeSources {
|
||||||
|
t.Fatal("config should reflect the persisted merge preference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) {
|
||||||
|
t.Fatal("response must never expose the application secret")
|
||||||
|
}
|
||||||
|
var cfg service.DanmakuRenderConfig
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||||
|
t.Fatalf("unexpected render config: %+v", cfg)
|
||||||
|
}
|
||||||
|
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||||
|
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||||
|
t.Fatalf("unexpected service config: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||||
|
if err != nil || user == nil {
|
||||||
|
t.Fatalf("read persisted user: %v", err)
|
||||||
|
}
|
||||||
|
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||||
|
t.Fatalf("preferences not persisted: %+v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
|
||||||
|
// playback_rate,服务端应当忽略而不是报错。
|
||||||
|
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||||
|
getDanmakuConfigHandler(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("playback_rate")) {
|
||||||
|
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c2)
|
||||||
|
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
|
||||||
|
t.Fatalf("settings response must not expose playback rate: %s", w2.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.PlayerVolume != 0.5 {
|
||||||
|
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设备管理接口。
|
||||||
|
//
|
||||||
|
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
|
||||||
|
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
|
||||||
|
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
|
||||||
|
|
||||||
|
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
|
||||||
|
// 判定用的内部标识,暴露出去只会方便伪造。
|
||||||
|
type deviceListPayload struct {
|
||||||
|
Devices []devicePayload `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type devicePayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name,omitempty"`
|
||||||
|
Client string `json:"client,omitempty"`
|
||||||
|
LastIP string `json:"last_ip,omitempty"`
|
||||||
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||||
|
LastPlayAt string `json:"last_play_at,omitempty"`
|
||||||
|
Kicked bool `json:"kicked"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Playing bool `json:"playing"`
|
||||||
|
Warnings int `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDevicePayload(d model.UserDevice) devicePayload {
|
||||||
|
out := devicePayload{
|
||||||
|
ID: d.ID,
|
||||||
|
DeviceID: d.DeviceID,
|
||||||
|
DeviceName: d.DeviceName,
|
||||||
|
Client: d.Client,
|
||||||
|
LastIP: d.LastIP,
|
||||||
|
Kicked: d.Kicked,
|
||||||
|
Online: d.Online,
|
||||||
|
Playing: d.Playing,
|
||||||
|
Warnings: d.Warnings,
|
||||||
|
}
|
||||||
|
if !d.LastSeenAt.IsZero() {
|
||||||
|
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
|
||||||
|
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
|
||||||
|
items := make([]devicePayload, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
items = append(items, toDevicePayload(d))
|
||||||
|
}
|
||||||
|
return deviceListPayload{Devices: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myDevicesHandler 返回当前会话用户的设备列表。
|
||||||
|
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myKickDeviceHandler 踢掉当前用户的一台设备。
|
||||||
|
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||||
|
if deviceID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
|
||||||
|
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserDevicesHandler 返回指定用户的设备列表。
|
||||||
|
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
|
||||||
|
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
|
||||||
|
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
|
||||||
|
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||||
|
if deviceID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
|
||||||
|
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
|
||||||
|
// 只做类型断言兜底,不做权限判断。
|
||||||
|
func sessionUserID(c *gin.Context) string {
|
||||||
|
if v, ok := c.Get(middleware.CtxUserID); ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
|
||||||
|
// 用于验证「只能操作自己的设备」这条边界。
|
||||||
|
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
|
||||||
|
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
|
||||||
|
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const secret = "test-secret"
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
// 测试里直接注入会话身份,绕开真实 JWT 解析。
|
||||||
|
if uid := c.GetHeader("X-Test-User"); uid != "" {
|
||||||
|
c.Set(middleware.CtxUserID, uid)
|
||||||
|
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||||
|
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||||
|
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||||
|
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||||
|
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
|
||||||
|
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||||
|
_ = secret
|
||||||
|
return router, svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDeviceUsers(t *testing.T, svc *service.Container) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, id := range []string{"user-a", "user-b"} {
|
||||||
|
if err := svc.Repo.User.Create(ctx, &model.User{
|
||||||
|
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
req.Header.Set("X-Test-User", userID)
|
||||||
|
if role != "" {
|
||||||
|
req.Header.Set("X-Test-Role", role)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// /me/devices 只能返回调用者自己的设备。
|
||||||
|
func TestMyDevicesScopedToCaller(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||||
|
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Devices []struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
|
||||||
|
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
|
||||||
|
}
|
||||||
|
// 设备指纹属于内部判定标识,不能下发。
|
||||||
|
if strings.Contains(w.Body.String(), "fingerprint") {
|
||||||
|
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
|
||||||
|
func TestKickForeignDeviceFails(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
|
||||||
|
if w.Code == http.StatusNoContent {
|
||||||
|
t.Fatal("user-a must not be able to kick user-b's device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
|
||||||
|
// 混成同一个空列表。
|
||||||
|
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramStatusAndBindCode(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"bound":false`) {
|
||||||
|
t.Fatalf("body = %s, want bound=false", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var code struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresIn int `json:"expires_in_seconds"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(code.Code) != 6 {
|
||||||
|
t.Fatalf("code = %q, want 6 chars", code.Code)
|
||||||
|
}
|
||||||
|
if code.ExpiresIn <= 0 {
|
||||||
|
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsMasksBotToken(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router.GET("/api/admin/settings", listSettingsHandler(svc))
|
||||||
|
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
|
||||||
|
t.Fatalf("bot token leaked: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "***") {
|
||||||
|
t.Fatalf("bot token should be masked: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
|
||||||
|
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const real = "123456:AAHsecretTOKEN"
|
||||||
|
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||||
|
|
||||||
|
body := `{"key":"telegram.bot_token","value":"12***EN"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Test-User", "admin-1")
|
||||||
|
req.Header.Set("X-Test-Role", "admin")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != real {
|
||||||
|
t.Fatalf("stored token = %q, want the original value preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||||
|
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||||
|
// 否则多用户会串到同一个 UserId 上。
|
||||||
|
func embyEffectiveUserID(c *gin.Context) string {
|
||||||
|
return embyUserID(c)
|
||||||
|
}
|
||||||
|
|
||||||
const embyCompatSessionTTL = 30 * time.Minute
|
const embyCompatSessionTTL = 30 * time.Minute
|
||||||
|
|
||||||
type embyCompatSession struct {
|
type embyCompatSession struct {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||||
|
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.User.Create(t.Context(), &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "tester",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "admin",
|
||||||
|
Tier: "plus",
|
||||||
|
IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
registerEmbyRoutes(router, secret, &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||||
|
const secret = "test-secret"
|
||||||
|
router := newEmbyCompatTestRouter(t, secret)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||||
|
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||||
|
// 的 HLS 兜底路由接走返回空 404。
|
||||||
|
var payload []any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
if len(payload) != 0 {
|
||||||
|
t.Fatalf("expected an empty array, got %v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||||
|
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||||
|
|
||||||
|
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||||
|
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||||
|
t.Fatalf("content type = %q, want application/json", ct)
|
||||||
|
}
|
||||||
|
var payload []any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||||
|
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||||
|
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||||
|
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||||
|
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
|
||||||
|
//
|
||||||
|
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
|
||||||
|
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
|
||||||
|
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
|
||||||
|
|
||||||
|
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := embyScopedUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||||
|
// YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
|
||||||
|
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
|
||||||
|
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
|
||||||
|
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
|
||||||
|
if seriesID == "" {
|
||||||
|
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
|
||||||
|
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
|
||||||
|
seriesID = parentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
|
||||||
|
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := embyScopedUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seriesID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||||
|
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
mediaID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if mediaID == "" {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||||
|
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
|
||||||
|
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
|
||||||
|
//
|
||||||
|
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
|
||||||
|
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
|
||||||
|
// 按自己处理,避免出现一条无人使用的越权路径。
|
||||||
|
func embyScopedUserID(c *gin.Context) string {
|
||||||
|
caller := embyEffectiveUserID(c)
|
||||||
|
requested := strings.TrimSpace(c.Param("userId"))
|
||||||
|
if requested == "" {
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
if requested == caller {
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
|
||||||
|
func embyEmptyItemsPayload() gin.H {
|
||||||
|
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
|
||||||
|
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
|
||||||
|
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.JWTSecret = "test-secret"
|
||||||
|
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
|
||||||
|
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
|
||||||
|
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
|
||||||
|
Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||||
|
return router, svc, userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
|
||||||
|
t.Helper()
|
||||||
|
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("X-Emby-Token", token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", string(body), err)
|
||||||
|
}
|
||||||
|
return payload.Items
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextUp 必须真的返回下一集,而不是空数组。
|
||||||
|
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||||
|
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||||
|
SeasonNum: 1, EpisodeNum: episode,
|
||||||
|
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if watched {
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||||
|
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||||
|
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
|
||||||
|
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
|
||||||
|
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||||
|
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "tv")
|
||||||
|
|
||||||
|
episodeIDs := map[int]string{}
|
||||||
|
for ep := 1; ep <= 3; ep++ {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||||
|
SeasonNum: 1, EpisodeNum: ep,
|
||||||
|
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
episodeIDs[ep] = m.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
|
||||||
|
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
|
||||||
|
WatchedAt: time.Now(), Completed: false,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
if id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
|
||||||
|
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], w.Body.String())
|
||||||
|
}
|
||||||
|
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||||
|
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有历史时必须返回合法空信封,不能 404/500。
|
||||||
|
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
|
||||||
|
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
_ = seedEmbyLibrary(t, svc, "tv")
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
|
||||||
|
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbySimilarReturnsCandidates(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "movie")
|
||||||
|
|
||||||
|
source := &model.Media{
|
||||||
|
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
|
||||||
|
Path: "/media/movie/source.mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(source).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other := &model.Media{
|
||||||
|
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
|
||||||
|
Path: "/media/movie/other.mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(other).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
if name, _ := items[0]["Name"].(string); name != "同类片" {
|
||||||
|
t.Fatalf("Name = %q, want 同类片", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres 必须返回真实类型与计数。
|
||||||
|
func TestEmbyGenresReturnsCounts(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "movie")
|
||||||
|
|
||||||
|
for i, genres := range []string{"Action,Drama", "Action"} {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
|
||||||
|
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
// 排序按计数降序:Action(2) 在前。
|
||||||
|
if name, _ := items[0]["Name"].(string); name != "Action" {
|
||||||
|
t.Fatalf("first Name = %q, want Action", name)
|
||||||
|
}
|
||||||
|
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
|
||||||
|
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
|
||||||
|
}
|
||||||
|
if id, _ := items[0]["Id"].(string); len(id) == 0 {
|
||||||
|
t.Fatal("genre item must carry a stable Id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
|
||||||
|
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
|
||||||
|
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
|
||||||
|
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
|
||||||
|
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "tv")
|
||||||
|
recent := time.Now().Add(-time.Minute)
|
||||||
|
older := time.Now().Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
|
||||||
|
t.Helper()
|
||||||
|
for ep := 1; ep <= 3; ep++ {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, SeriesID: seriesID, Title: title,
|
||||||
|
SeasonNum: 1, EpisodeNum: ep,
|
||||||
|
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch ep {
|
||||||
|
case 1:
|
||||||
|
watchedID = m.ID
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||||
|
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
nextID = m.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return watchedID, nextID
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = seedSeries("series-hot", "热门剧", recent)
|
||||||
|
_, wantNext := seedSeries("series-cold", "目标剧", older)
|
||||||
|
|
||||||
|
token := signedTestToken(t, "test-secret")
|
||||||
|
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
|
||||||
|
if global.Code != http.StatusOK {
|
||||||
|
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
|
||||||
|
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
|
||||||
|
if scoped.Code != http.StatusOK {
|
||||||
|
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
|
||||||
|
}
|
||||||
|
if id, _ := items[0]["Id"].(string); id != wantNext {
|
||||||
|
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
|
||||||
|
}
|
||||||
|
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
|
||||||
|
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
|
||||||
|
if empty.Code != http.StatusOK {
|
||||||
|
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
|
||||||
|
if pathScoped.Code != http.StatusOK {
|
||||||
|
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
|
||||||
|
}
|
||||||
|
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
|
||||||
|
if len(pathItems) != 1 {
|
||||||
|
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
|
||||||
|
}
|
||||||
|
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
|
||||||
|
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,27 +29,87 @@ var embyPlaceholderPNG = []byte{
|
|||||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
clearEmbyImageNoStoreHeaders(c)
|
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
}
|
||||||
defer cancel()
|
}
|
||||||
req := c.Request.WithContext(ctx)
|
|
||||||
id := c.Param("id")
|
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||||
imgType := strings.ToLower(c.Param("type"))
|
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
if err != nil || raw == "" {
|
return func(c *gin.Context) {
|
||||||
embyServePlaceholderImage(c)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||||
|
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||||
|
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
|
if svc == nil || svc.Emby == nil || id == "" {
|
||||||
|
c.JSON(http.StatusOK, []any{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if svc.ImageProxy == nil {
|
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||||
embyServePlaceholderImage(c)
|
if infos == nil {
|
||||||
|
infos = []map[string]any{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, infos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||||
|
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||||
|
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||||
|
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uid := strings.TrimSpace(c.Param("userId"))
|
||||||
|
raw := ""
|
||||||
|
if svc != nil && svc.Emby != nil && uid != "" {
|
||||||
|
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||||
|
}
|
||||||
|
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||||
|
embyMissingAvatar(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||||
embyServePlaceholderImage(c)
|
embyMissingAvatar(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||||
|
func embyMissingAvatar(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||||
c.Writer.Header().Del("Cache-Control")
|
c.Writer.Header().Del("Cache-Control")
|
||||||
c.Writer.Header().Del("Pragma")
|
c.Writer.Header().Del("Pragma")
|
||||||
|
|||||||
@@ -13,13 +13,7 @@ import (
|
|||||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
splitOpt := func(s string) []string {
|
splitOpt := func(s string) []string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
|||||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
StartIndex: offset,
|
StartIndex: offset,
|
||||||
|
SeasonIndex: parseEmbySeasonIndexQuery(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
|
||||||
|
//
|
||||||
|
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
|
||||||
|
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
|
||||||
|
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
|
||||||
|
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
|
||||||
|
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func embyFirstNonEmptyString(values ...string) string {
|
func embyFirstNonEmptyString(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,15 +130,12 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
|
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
|
||||||
|
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
|
||||||
|
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -147,13 +148,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if svc != nil && svc.Emby != nil {
|
if svc != nil && svc.Emby != nil {
|
||||||
uid := firstQueryValue(c, "UserId", "userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = c.Param("userId")
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -209,7 +204,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
params := service.ItemsParams{
|
params := service.ItemsParams{
|
||||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
UserID: embyEffectiveUserID(c),
|
||||||
ParentID: c.Param("id"),
|
ParentID: c.Param("id"),
|
||||||
Limit: 500,
|
Limit: 500,
|
||||||
}
|
}
|
||||||
@@ -226,15 +221,35 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
parentID := firstQueryValue(c, "SeasonId", "seasonId")
|
parentID := firstQueryValue(c, "SeasonId", "seasonId")
|
||||||
|
// 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤,
|
||||||
|
// /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。
|
||||||
|
seasonIndex := parseEmbySeasonIndexQuery(c)
|
||||||
if parentID == "" {
|
if parentID == "" {
|
||||||
parentID = c.Param("id")
|
parentID = c.Param("id")
|
||||||
|
} else {
|
||||||
|
// SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者
|
||||||
|
// 不一致时把结果过滤成空集。
|
||||||
|
seasonIndex = nil
|
||||||
|
}
|
||||||
|
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
|
||||||
|
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
|
||||||
|
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
|
if startIndex < 0 {
|
||||||
|
startIndex = 0
|
||||||
}
|
}
|
||||||
params := service.ItemsParams{
|
params := service.ItemsParams{
|
||||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
UserID: embyEffectiveUserID(c),
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
IncludeItemTypes: []string{"Episode"},
|
IncludeItemTypes: []string{"Episode"},
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
Limit: 500,
|
Limit: limit,
|
||||||
|
StartIndex: startIndex,
|
||||||
|
SeasonIndex: seasonIndex,
|
||||||
}
|
}
|
||||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Package handler — Emby / Jellyfin 媒体分段(片头、片尾)兼容接口。
|
||||||
|
//
|
||||||
|
// GET /MediaSegments/{itemId}
|
||||||
|
// GET /Items/{itemId}/MediaSegments
|
||||||
|
// GET /Users/{userId}/Items/{itemId}/MediaSegments
|
||||||
|
//
|
||||||
|
// 契约对齐 Jellyfin 10.10 引入的 Media Segments API(Emby 采用同一形状),
|
||||||
|
// 也是 TheIntroDB 官方 Jellyfin 插件走的同一条路:
|
||||||
|
//
|
||||||
|
// QueryResult<MediaSegmentDto> = {"Items": [...], "TotalRecordCount": N}
|
||||||
|
// MediaSegmentDto = {"Id", "ItemId", "Type", "StartTicks", "EndTicks"}
|
||||||
|
//
|
||||||
|
// 时间是 .NET ticks(1 tick = 100ns,即每秒 10,000,000、每毫秒 10,000);
|
||||||
|
// Type 是枚举名字符串 Intro / Outro / Recap / Preview / Commercial。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 第三方客户端会在起播前后同步请求分段,不能被一次外网抓取无限拖住。超时后
|
||||||
|
// 退回已有缓存(可能为空),请求本身永远不失败。
|
||||||
|
const embyMediaSegmentsFetchBudget = 5 * time.Second
|
||||||
|
|
||||||
|
// 1 秒 = 10,000,000 ticks => 1 毫秒 = 10,000 ticks。
|
||||||
|
const embyTicksPerMillisecond int64 = 10_000
|
||||||
|
|
||||||
|
func embyMediaSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 未知条目、虚拟剧集/季、远程 Emby 挂载、以及当前不可见的内容一律返回
|
||||||
|
// 空结果而不是 404:客户端会把 404 判成「条目损坏」(同 emby_routes.go
|
||||||
|
// 里 AdditionalParts 的说明),而「没有可跳过的片段」本来就是个合法状态。
|
||||||
|
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||||
|
if svc == nil || svc.Repo == nil || svc.Segments == nil {
|
||||||
|
c.JSON(http.StatusOK, empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
||||||
|
if service.IsEmbyRemoteID(id) {
|
||||||
|
c.JSON(http.StatusOK, empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
|
||||||
|
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||||
|
c.JSON(http.StatusOK, empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), embyMediaSegmentsFetchBudget)
|
||||||
|
defer cancel()
|
||||||
|
rows, listErr := svc.Segments.ListForPlayback(ctx, m)
|
||||||
|
if listErr != nil && svc.Log != nil {
|
||||||
|
svc.Log.Debug("emby media segments lookup failed",
|
||||||
|
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
items := embySegmentItems(m, rows, embyRequestedSegmentTypes(c))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"Items": items, "TotalRecordCount": len(items)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embySegmentItems 把库内片段转换成 MediaSegmentDto 列表。
|
||||||
|
func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string]bool) []gin.H {
|
||||||
|
// 末段在库内用 end_ms = 0 表示「一直到片尾」(TheIntroDB 对片尾返回 end_ms: null),
|
||||||
|
// 这里必须换算成真实结束时间;拿不到时长就丢弃该分段,否则会给出一个零长度区间,
|
||||||
|
// 客户端要么忽略要么画出一个错误的跳转点。
|
||||||
|
durationMs := int64(m.DurationSec) * 1000
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
typeName := embySegmentTypeName(row.Kind)
|
||||||
|
if typeName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(want) > 0 && !want[typeName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endMs := row.EndMs
|
||||||
|
if endMs <= 0 {
|
||||||
|
if durationMs <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endMs = durationMs
|
||||||
|
}
|
||||||
|
if endMs <= row.StartMs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"Id": row.ID,
|
||||||
|
"ItemId": m.ID,
|
||||||
|
"Type": typeName,
|
||||||
|
"StartTicks": row.StartMs * embyTicksPerMillisecond,
|
||||||
|
"EndTicks": endMs * embyTicksPerMillisecond,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// embySegmentTypeName 把库内 kind 映射成 Emby/Jellyfin 的 MediaSegmentType 名字。
|
||||||
|
// 库内的 credits 取自 TheIntroDB 的字段名,在 Emby 一侧对应 Outro。
|
||||||
|
func embySegmentTypeName(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case model.SegmentKindIntro:
|
||||||
|
return "Intro"
|
||||||
|
case model.SegmentKindRecap:
|
||||||
|
return "Recap"
|
||||||
|
case model.SegmentKindCredits:
|
||||||
|
return "Outro"
|
||||||
|
case model.SegmentKindPreview:
|
||||||
|
return "Preview"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyRequestedSegmentTypes 解析 includeSegmentTypes(Jellyfin 的过滤参数)。
|
||||||
|
// 支持重复参数与逗号分隔两种写法;返回空集合表示不过滤。
|
||||||
|
//
|
||||||
|
// 只认枚举名字符串。数字枚举虽然 ASP.NET 模型绑定也接受,但各家定义的顺序并
|
||||||
|
// 不一致,猜错会把过滤结果算错;认不出来时按「不过滤」处理,返回的是超集,
|
||||||
|
// 客户端自己仍会再过滤一次。
|
||||||
|
func embyRequestedSegmentTypes(c *gin.Context) map[string]bool {
|
||||||
|
raw := make([]string, 0, 4)
|
||||||
|
for _, key := range []string{"includeSegmentTypes", "IncludeSegmentTypes", "includesegmenttypes"} {
|
||||||
|
raw = append(raw, c.QueryArray(key)...)
|
||||||
|
}
|
||||||
|
want := make(map[string]bool, len(raw))
|
||||||
|
for _, value := range raw {
|
||||||
|
for _, part := range strings.Split(value, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Intro", "Outro", "Recap", "Preview", "Commercial"} {
|
||||||
|
if strings.EqualFold(part, name) {
|
||||||
|
want[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return want
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type embySegmentsPayload struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
ItemID string `json:"ItemId"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
StartTicks int64 `json:"StartTicks"`
|
||||||
|
EndTicks int64 `json:"EndTicks"`
|
||||||
|
} `json:"Items"`
|
||||||
|
TotalRecordCount int `json:"TotalRecordCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
|
||||||
|
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
|
||||||
|
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
|
||||||
|
|
||||||
|
const (
|
||||||
|
embySegmentsMovieDurationSec = 8880
|
||||||
|
embySegmentsTicksPerSecond = 10_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.User.Create(t.Context(), &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "tester",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "admin",
|
||||||
|
Tier: "plus",
|
||||||
|
IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "movie-1"},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "Inception",
|
||||||
|
Path: "D:\\media\\movies\\Inception.mkv",
|
||||||
|
DurationSec: durationSec,
|
||||||
|
TMDbID: 27205,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var calls int32
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(embySegmentsProviderBody))
|
||||||
|
}))
|
||||||
|
t.Cleanup(provider.Close)
|
||||||
|
|
||||||
|
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
|
||||||
|
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
|
||||||
|
|
||||||
|
const secret = "test-secret"
|
||||||
|
router := gin.New()
|
||||||
|
registerEmbyRoutes(router, secret, &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||||
|
Segments: segments,
|
||||||
|
Log: zap.NewNop(),
|
||||||
|
})
|
||||||
|
return router, secret, &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload embySegmentsPayload
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("%s decode: %v", path, err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
|
||||||
|
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||||
|
|
||||||
|
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||||||
|
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
|
||||||
|
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
intro := payload.Items[0]
|
||||||
|
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
|
||||||
|
t.Fatalf("intro segment = %#v", intro)
|
||||||
|
}
|
||||||
|
// start_ms null 表示从片头开始。
|
||||||
|
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
|
||||||
|
t.Fatalf("intro ticks = %d..%d, want 0..%d",
|
||||||
|
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
|
||||||
|
// 否则客户端会拿到一个零长度区间。
|
||||||
|
outro := payload.Items[1]
|
||||||
|
if outro.Type != "Outro" {
|
||||||
|
t.Fatalf("credits should map to Outro, got %q", outro.Type)
|
||||||
|
}
|
||||||
|
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
|
||||||
|
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
|
||||||
|
}
|
||||||
|
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
|
||||||
|
t.Fatalf("outro EndTicks = %d, want the media duration %d",
|
||||||
|
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
|
||||||
|
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||||
|
|
||||||
|
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
|
||||||
|
for _, path := range []string{
|
||||||
|
"/MediaSegments/movie-1",
|
||||||
|
"/mediasegments/movie-1",
|
||||||
|
"/Items/movie-1/MediaSegments",
|
||||||
|
"/items/movie-1/mediasegments",
|
||||||
|
"/Users/user-1/Items/movie-1/MediaSegments",
|
||||||
|
} {
|
||||||
|
payload := embySegmentsRequest(t, router, secret, path)
|
||||||
|
if len(payload.Items) != 2 {
|
||||||
|
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(calls); got != 1 {
|
||||||
|
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
|
||||||
|
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||||
|
|
||||||
|
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
|
||||||
|
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||||||
|
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
|
||||||
|
}
|
||||||
|
if payload.Items[0].Type != "Intro" {
|
||||||
|
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
|
||||||
|
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
|
||||||
|
if payload.TotalRecordCount != 2 {
|
||||||
|
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
|
||||||
|
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
|
||||||
|
// 只能丢弃;有明确结束点的 intro 必须保留。
|
||||||
|
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
|
||||||
|
|
||||||
|
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||||||
|
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||||||
|
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
|
||||||
|
}
|
||||||
|
if payload.Items[0].Type != "Intro" {
|
||||||
|
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
|
||||||
|
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||||
|
|
||||||
|
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
|
||||||
|
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
|
||||||
|
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
|
||||||
|
t.Fatalf("unknown item payload = %#v", payload)
|
||||||
|
}
|
||||||
|
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
|
||||||
|
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
|
||||||
|
if len(payload.Items) != 0 {
|
||||||
|
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(calls); got != 0 {
|
||||||
|
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
|||||||
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||||
progressReq := httptest.NewRequest(
|
progressReq := httptest.NewRequest(
|
||||||
http.MethodPost,
|
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,
|
nil,
|
||||||
)
|
)
|
||||||
wProgress := httptest.NewRecorder()
|
wProgress := httptest.NewRecorder()
|
||||||
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
|||||||
if hist.PositionMs != 30000 {
|
if hist.PositionMs != 30000 {
|
||||||
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
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 能够包含该远程条目
|
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||||
resumableReq := httptest.NewRequest(
|
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": {},
|
"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 请求路径:
|
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||||
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
|
|||||||
|
|
||||||
// 检查第一段是否为 Emby 根路由关键字
|
// 检查第一段是否为 Emby 根路由关键字
|
||||||
firstLower := strings.ToLower(segments[0])
|
firstLower := strings.ToLower(segments[0])
|
||||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
|
||||||
// 不是 Emby 相关路径,保持原样
|
// 不是 Emby 相关路径,保持原样
|
||||||
return original, false
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -13,10 +16,7 @@ import (
|
|||||||
|
|
||||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -27,10 +27,98 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
embyAttachRequestTokenToMediaSources(c, out)
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
// 在后台把本次条目的云盘直链换好:播放器拿到 PlaybackInfo 后通常还要
|
||||||
|
// 1–2 秒才请求 /Videos/{id}/stream,把换链开销落在这段等待里。
|
||||||
|
embyPrewarmPlaybackTargets(svc, c, out)
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyPrewarmTimeout 是单次预热的等待上限。115 开放平台在跨太平洋线路上单次
|
||||||
|
// 换链实测 0.4–1.1s,这里给足余量;超时只是没预热成功,不影响后续播放。
|
||||||
|
const embyPrewarmTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// embyPrewarmInFlight 去重同一个条目的并发预热(首页刷新会并发请求多个接口,
|
||||||
|
// 同一条目可能在短时间内被多次请求)。
|
||||||
|
var embyPrewarmInFlight sync.Map
|
||||||
|
|
||||||
|
// embyPrewarmSlots 限制同时进行的预热数量。客户端可能批量预取 PlaybackInfo
|
||||||
|
// (逐个剧集的预取请求),预热只是优化,不能反过来把 115 换链接口打出突发。
|
||||||
|
// 名额满时直接跳过:排队等待的预热往往等真正播放时已经没意义了。
|
||||||
|
var embyPrewarmSlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
|
// embyPrewarmPlaybackTargets 在后台预热本次 PlaybackInfo 涉及条目的云盘直链。
|
||||||
|
//
|
||||||
|
// 起播链路里最贵的一步是「服务端拿 pickcode 去 115 开放平台换直链」:服务器在
|
||||||
|
// 洛杉矶、115 接口在国内,冷启动实测 0.4–1.1s;之后 45 分钟内命中进程内缓存。
|
||||||
|
// 播放器在 PlaybackInfo 与真正拉流之间有几秒间隔,这里把换链放到那段间隔里,
|
||||||
|
// 起播时就只剩纯网络耗时。
|
||||||
|
//
|
||||||
|
// 只处理云盘/strm 条目,且失败一律静默忽略:预热是尽力而为的优化,不能影响
|
||||||
|
// PlaybackInfo 的正常返回。
|
||||||
|
func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[string]any) {
|
||||||
|
if svc == nil || svc.Strm == nil || svc.Repo == nil || svc.Repo.Media == nil || out == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := embyPrewarmMediaIDs(out)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
// 预热是给「后续请求」用的:即便本次 PlaybackInfo 的连接断开,
|
||||||
|
// 也要把换链跑完。
|
||||||
|
base := context.WithoutCancel(c.Request.Context())
|
||||||
|
for _, mediaID := range ids {
|
||||||
|
if _, loaded := embyPrewarmInFlight.LoadOrStore(mediaID, struct{}{}); loaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(id string) {
|
||||||
|
defer embyPrewarmInFlight.Delete(id)
|
||||||
|
select {
|
||||||
|
case embyPrewarmSlots <- struct{}{}:
|
||||||
|
defer func() { <-embyPrewarmSlots }()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(base, embyPrewarmTimeout)
|
||||||
|
defer cancel()
|
||||||
|
m, err := svc.Repo.Media.FindByID(ctx, id)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(m.STRMURL)
|
||||||
|
if raw == "" || !service.IsStrmMediaRow(m) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 解析结果由 strm 层按 pickcode+UA 缓存;已缓存时这里是空转。
|
||||||
|
_, _ = svc.Strm.ResolvePlayTargetWithUA(ctx, raw, userAgent)
|
||||||
|
}(mediaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyPrewarmMediaIDs 取出 PlaybackInfo 载荷里 MediaSources 的条目 ID。
|
||||||
|
func embyPrewarmMediaIDs(out map[string]any) []string {
|
||||||
|
sources, ok := out["MediaSources"].([]map[string]any)
|
||||||
|
if !ok || len(sources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(sources))
|
||||||
|
seen := make(map[string]struct{}, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
id, _ := src["Id"].(string)
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[id]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
||||||
// MediaSource's MediaStreams via its Emby index
|
// MediaSource's MediaStreams via its Emby index
|
||||||
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
||||||
@@ -47,10 +135,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
// The official-format route carries a :format suffix (Stream.ass /
|
// The official-format route carries a :format suffix (Stream.ass /
|
||||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPrewarmTestContainer(t *testing.T) *service.Container {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}, &model.StrmAccount{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if sqlDB, err := db.DB(); err == nil {
|
||||||
|
// 内存库 + 后台预热协程:限制单连接,避免新连接拿到空白的 :memory:。
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
return &service.Container{
|
||||||
|
Log: zap.NewNop(),
|
||||||
|
Repo: repos,
|
||||||
|
Strm: service.NewStrmService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPrewarmTestContext() *gin.Context {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/emby/Items/media-1/PlaybackInfo", nil)
|
||||||
|
c.Request.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbyPrewarmMediaIDsExtractsDeduplicates(t *testing.T) {
|
||||||
|
out := map[string]any{
|
||||||
|
"MediaSources": []map[string]any{
|
||||||
|
{"Id": "src-1"},
|
||||||
|
{"Id": " src-1 "},
|
||||||
|
{"Id": "src-2"},
|
||||||
|
{"Id": ""},
|
||||||
|
{"Name": "no id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := embyPrewarmMediaIDs(out)
|
||||||
|
if len(got) != 2 || got[0] != "src-1" || got[1] != "src-2" {
|
||||||
|
t.Fatalf("ids = %v, want [src-1 src-2]", got)
|
||||||
|
}
|
||||||
|
if got := embyPrewarmMediaIDs(map[string]any{}); len(got) != 0 {
|
||||||
|
t.Fatalf("missing MediaSources should yield no ids, got %v", got)
|
||||||
|
}
|
||||||
|
if got := embyPrewarmMediaIDs(map[string]any{"MediaSources": []any{}}); len(got) != 0 {
|
||||||
|
t.Fatalf("foreign payload shape should yield no ids, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预热是异步的:调用必须立即返回,并且协程结束后不能残留去重标记。
|
||||||
|
func TestEmbyPrewarmPlaybackTargetsRunsAsyncAndCleansUp(t *testing.T) {
|
||||||
|
svc := newPrewarmTestContainer(t)
|
||||||
|
if err := svc.Repo.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "media-1"},
|
||||||
|
Title: "Cloud",
|
||||||
|
Path: "cloud://cloud115/Movie.mkv",
|
||||||
|
Container: "strm",
|
||||||
|
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=missing&pickcode=pc1",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
embyPrewarmInFlight.Delete("media-1")
|
||||||
|
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(), out)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("embyPrewarmPlaybackTargets blocked the caller")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台协程应很快跑完并释放去重标记,否则同一条目后续再也预热不了。
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, busy := embyPrewarmInFlight.Load("media-1"); !busy {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("prewarm in-flight marker leaked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各种缺数据的情况都不允许 panic 或阻塞:预热只是尽力而为的优化。
|
||||||
|
func TestEmbyPrewarmPlaybackTargetsIsNilSafe(t *testing.T) {
|
||||||
|
svc := newPrewarmTestContainer(t)
|
||||||
|
c := newPrewarmTestContext()
|
||||||
|
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
svc *service.Container
|
||||||
|
out map[string]any
|
||||||
|
}{
|
||||||
|
{name: "空容器", svc: &service.Container{}, out: out},
|
||||||
|
{name: "无 Strm", svc: &service.Container{Repo: svc.Repo}, out: out},
|
||||||
|
{name: "无 Repo", svc: &service.Container{Strm: svc.Strm}, out: out},
|
||||||
|
{name: "nil 载荷", svc: svc, out: nil},
|
||||||
|
{name: "无 MediaSources", svc: svc, out: map[string]any{}},
|
||||||
|
{name: "条目不存在", svc: svc, out: out},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
embyPrewarmPlaybackTargets(tc.svc, c, tc.out)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地文件条目不该触发换链预热(没有云盘直链可预热)。
|
||||||
|
func TestEmbyPrewarmSkipsLocalMedia(t *testing.T) {
|
||||||
|
svc := newPrewarmTestContainer(t)
|
||||||
|
if err := svc.Repo.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "local-1"},
|
||||||
|
Title: "Local",
|
||||||
|
Path: "/media/movies/Local.mkv",
|
||||||
|
LibraryID: "lib-1",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
embyPrewarmInFlight.Delete("local-1")
|
||||||
|
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(),
|
||||||
|
map[string]any{"MediaSources": []map[string]any{{"Id": "local-1"}}})
|
||||||
|
|
||||||
|
// 协程要么已经跑完(标记被清掉),要么根本没起;两种都不该留下标记。
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, busy := embyPrewarmInFlight.Load("local-1"); !busy {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("local media must not leave a prewarm marker")
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ type embyPlayingReq struct {
|
|||||||
PositionLower int64 `json:"positionTicks"`
|
PositionLower int64 `json:"positionTicks"`
|
||||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||||
RunTimeLower int64 `json:"runTimeTicks"`
|
RunTimeLower int64 `json:"runTimeTicks"`
|
||||||
|
PlaySessionID string `json:"PlaySessionId"`
|
||||||
|
PlaySession string `json:"playSessionId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if runTime == 0 {
|
if runTime == 0 {
|
||||||
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
|
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
|
||||||
}
|
}
|
||||||
|
playSessionID := embyFirstNonEmptyString(
|
||||||
|
req.PlaySessionID,
|
||||||
|
req.PlaySession,
|
||||||
|
firstQueryValue(c, "PlaySessionId", "playSessionId"),
|
||||||
|
)
|
||||||
if itemID == "" {
|
if itemID == "" {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
return
|
return
|
||||||
@@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -85,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
mid := c.Param("itemId")
|
mid := c.Param("itemId")
|
||||||
if uid == "" || mid == "" {
|
if uid == "" || mid == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
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 {
|
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
mid := c.Param("itemId")
|
mid := c.Param("itemId")
|
||||||
if uid == "" || mid == "" {
|
if uid == "" || mid == "" {
|
||||||
c.Status(http.StatusBadRequest)
|
c.Status(http.StatusBadRequest)
|
||||||
|
|||||||
@@ -115,12 +115,31 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
|||||||
|
|
||||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||||
|
//
|
||||||
|
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||||
|
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||||
|
// 返回 text/plain 的 404。
|
||||||
|
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||||
|
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||||
|
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||||
grp.HEAD("/items/:id/images/:type", 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))
|
||||||
|
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||||
|
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||||
@@ -177,15 +196,20 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
|||||||
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
|
||||||
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
|
||||||
|
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||||
|
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
|
||||||
|
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||||
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Genres", embyEmptyItemsHandler(svc))
|
auth.GET("/Genres", embyGenresHandler(svc))
|
||||||
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
|
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
|
||||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||||
@@ -202,6 +226,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
|||||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
|
|
||||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||||
|
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||||
|
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||||
|
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||||
|
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||||
|
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||||
|
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||||
|
|||||||
@@ -40,15 +40,19 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
|||||||
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
|
auth.GET("/shows/nextup", embyNextUpHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
|
||||||
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||||
|
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||||
|
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
|
||||||
|
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||||
|
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||||
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/genres", embyEmptyItemsHandler(svc))
|
auth.GET("/genres", embyGenresHandler(svc))
|
||||||
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
|
auth.GET("/items/:id/similar", embySimilarHandler(svc))
|
||||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embySeasonEpisodesRouter builds an Emby-compatible router over one series with
|
||||||
|
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
||||||
|
// real clients send.
|
||||||
|
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
return embySeriesRouter(t, []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "s1e1"},
|
||||||
|
Title: "Test Show",
|
||||||
|
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "s2e1"},
|
||||||
|
Title: "Test Show",
|
||||||
|
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||||
|
SeasonNum: 2,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// embySeriesRouter builds an Emby-compatible router with a single tv library and
|
||||||
|
// one series made of the given episode rows. Callers omit LibraryID: it is filled
|
||||||
|
// in here once the library exists.
|
||||||
|
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if sqlDB, err := db.DB(); err == nil {
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.User.Create(t.Context(), &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "tester",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "admin",
|
||||||
|
Tier: "plus",
|
||||||
|
IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatalf("create library: %v", err)
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].LibraryID = lib.ID
|
||||||
|
if err := db.Create(&rows[i]).Error; err != nil {
|
||||||
|
t.Fatalf("create media: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = "test-secret"
|
||||||
|
router := gin.New()
|
||||||
|
registerEmbyRoutes(router, secret, &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve the virtual series id the way a client would.
|
||||||
|
seriesReq := httptest.NewRequest(http.MethodGet, "/Items?ParentId="+lib.ID+"&IncludeItemTypes=Series", nil)
|
||||||
|
seriesReq.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||||
|
seriesRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(seriesRec, seriesReq)
|
||||||
|
if seriesRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("series lookup status=%d body=%s", seriesRec.Code, seriesRec.Body.String())
|
||||||
|
}
|
||||||
|
var seriesPayload struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(seriesRec.Body.Bytes(), &seriesPayload); err != nil {
|
||||||
|
t.Fatalf("decode series: %v", err)
|
||||||
|
}
|
||||||
|
if len(seriesPayload.Items) != 1 {
|
||||||
|
t.Fatalf("want one series, got %#v", seriesPayload.Items)
|
||||||
|
}
|
||||||
|
seriesID, _ := seriesPayload.Items[0]["Id"].(string)
|
||||||
|
if seriesID == "" {
|
||||||
|
t.Fatalf("series payload has no Id: %#v", seriesPayload.Items[0])
|
||||||
|
}
|
||||||
|
return router, secret, seriesID
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
|
||||||
|
t.Helper()
|
||||||
|
path := "/Shows/" + seriesID + "/Seasons"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var seasons struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||||
|
t.Fatalf("decode seasons: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range seasons.Items {
|
||||||
|
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
|
||||||
|
if id, _ := s["Id"].(string); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("season %d not found in %#v", index, seasons.Items)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
TotalRecordCount float64 `json:"TotalRecordCount"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return payload.Items, int(payload.TotalRecordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||||
|
t.Helper()
|
||||||
|
items, total := fetchEpisodeItems(t, router, secret, path)
|
||||||
|
ids := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
id, _ := item["Id"].(string)
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
||||||
|
// test: Emby clients scope episodes with ?Season=<index> (not only SeasonId), and
|
||||||
|
// the endpoint used to ignore it and return every season of the series.
|
||||||
|
func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
|
||||||
|
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantIDs []string
|
||||||
|
}{
|
||||||
|
{name: "season 1", query: "Season=1", wantIDs: []string{"s1e1"}},
|
||||||
|
{name: "season 2", query: "Season=2", wantIDs: []string{"s2e1"}},
|
||||||
|
{name: "season index alias", query: "SeasonIndex=2", wantIDs: []string{"s2e1"}},
|
||||||
|
{name: "no season returns all", query: "", wantIDs: []string{"s1e1", "s2e1"}},
|
||||||
|
{name: "unknown season is empty", query: "Season=9", wantIDs: []string{}},
|
||||||
|
{name: "non numeric season falls back to all", query: "Season=Specials", wantIDs: []string{"s1e1", "s2e1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
path := "/Shows/" + seriesID + "/Episodes"
|
||||||
|
if tc.query != "" {
|
||||||
|
path += "?" + tc.query
|
||||||
|
}
|
||||||
|
ids, total := fetchEpisodeIDs(t, router, secret, path)
|
||||||
|
if len(ids) != len(tc.wantIDs) {
|
||||||
|
t.Fatalf("episode ids = %#v (total=%d), want %#v", ids, total, tc.wantIDs)
|
||||||
|
}
|
||||||
|
for i := range tc.wantIDs {
|
||||||
|
if ids[i] != tc.wantIDs[i] {
|
||||||
|
t.Fatalf("episode ids = %#v, want %#v", ids, tc.wantIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != len(tc.wantIDs) {
|
||||||
|
t.Fatalf("TotalRecordCount = %d, want %d", total, len(tc.wantIDs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbyShowEpisodesSeasonIdStillWins pins that the virtual SeasonId form keeps
|
||||||
|
// working, and that a conflicting Season query cannot empty it out.
|
||||||
|
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||||
|
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||||
|
|
||||||
|
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||||
|
|
||||||
|
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
||||||
|
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||||
|
t.Fatalf("SeasonId episodes = %#v, want [s1e1]", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale/conflicting season number must not override the explicit season id.
|
||||||
|
ids, _ = fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID+"&Season=2")
|
||||||
|
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||||
|
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldedDuplicateSeriesRows models a season where two files resolve to the same
|
||||||
|
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
|
||||||
|
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
|
||||||
|
func foldedDuplicateSeriesRows() []model.Media {
|
||||||
|
return []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e1"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e2"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 2,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e2half"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 2,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
|
||||||
|
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
|
||||||
|
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
|
||||||
|
// be computed before that fold, so clients that page until they have
|
||||||
|
// TotalRecordCount items kept re-requesting the same page and never rendered the
|
||||||
|
// season.
|
||||||
|
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
|
||||||
|
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||||
|
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||||
|
|
||||||
|
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
|
||||||
|
if total != len(items) {
|
||||||
|
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
|
||||||
|
}
|
||||||
|
// The folded episode must still carry both files so clients can switch version.
|
||||||
|
versioned := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
|
||||||
|
versioned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if versioned != 1 {
|
||||||
|
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
|
||||||
|
// the episode list. Ignoring them made every page identical, so a client paging
|
||||||
|
// until it has TotalRecordCount items could never finish loading the season.
|
||||||
|
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
|
||||||
|
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||||
|
base := "/Shows/" + seriesID + "/Episodes?Season=1"
|
||||||
|
|
||||||
|
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
|
||||||
|
if total != 2 || len(first) != 1 {
|
||||||
|
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
|
||||||
|
}
|
||||||
|
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
|
||||||
|
if len(second) != 1 || second[0] == first[0] {
|
||||||
|
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
|
||||||
|
}
|
||||||
|
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
|
||||||
|
if len(third) != 0 {
|
||||||
|
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
|
||||||
|
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
|
||||||
|
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
|
||||||
|
// 所以集号仍占 11,小数通过条目名体现。
|
||||||
|
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
|
||||||
|
router, secret, seriesID := embySeriesRouter(t, []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e11"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 11,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e11half"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 11,
|
||||||
|
EpisodeFraction: 0.5,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "e12"},
|
||||||
|
Title: "三月的狮子",
|
||||||
|
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 12,
|
||||||
|
Container: "mkv",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
|
||||||
|
if total != 3 || len(items) != 3 {
|
||||||
|
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
|
||||||
|
}
|
||||||
|
wantOrder := []string{"e11", "e11half", "e12"}
|
||||||
|
for i, want := range wantOrder {
|
||||||
|
id, _ := items[i]["Id"].(string)
|
||||||
|
if id != want {
|
||||||
|
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
sources, _ := item["MediaSources"].([]any)
|
||||||
|
if len(sources) != 1 {
|
||||||
|
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
halfName, _ := items[1]["Name"].(string)
|
||||||
|
if !strings.HasPrefix(halfName, "第 11.5 集") {
|
||||||
|
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
|
||||||
|
}
|
||||||
|
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
|
||||||
|
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,7 @@ import (
|
|||||||
|
|
||||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("userId")
|
uid := embyEffectiveUserID(c)
|
||||||
if uid == "" {
|
|
||||||
uid = embyUserID(c)
|
|
||||||
}
|
|
||||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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.Use(middleware.GzipAPI())
|
||||||
{
|
{
|
||||||
api.GET("/health", healthCheck)
|
api.GET("/health", healthCheck)
|
||||||
|
api.HEAD("/health", healthCheck)
|
||||||
api.GET("/version", versionInfo)
|
api.GET("/version", versionInfo)
|
||||||
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
||||||
|
|
||||||
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
||||||
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||||
|
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||||
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
|
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
|
||||||
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||||
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
|
||||||
|
//
|
||||||
|
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
|
||||||
|
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
|
||||||
|
// 到了筛选条件之外的条目」这类不一致。
|
||||||
|
|
||||||
|
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Param("id")
|
||||||
|
facets, err := svc.Media.LibraryFacets(
|
||||||
|
c.Request.Context(),
|
||||||
|
libraryID,
|
||||||
|
mediaVisibilityForRequest(c, svc),
|
||||||
|
svc.Discovery,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalOrCanceled(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, facets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Param("id")
|
||||||
|
filters := parseLibraryFilters(c)
|
||||||
|
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
|
||||||
|
media, err := svc.Media.RandomMedia(
|
||||||
|
c.Request.Context(),
|
||||||
|
libraryID,
|
||||||
|
mediaVisibilityForRequest(c, svc),
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalOrCanceled(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, media)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
|
||||||
|
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(context.Background(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
c.Set(middleware.CtxUserID, userID)
|
||||||
|
c.Set(middleware.CtxUserRole, "user")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||||
|
return router, svc, userID, lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := svc.Repo.DB.Create(row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w.Code, w.Body.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var facets struct {
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"genres"`
|
||||||
|
YearMin int `json:"year_min"`
|
||||||
|
YearMax int `json:"year_max"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &facets); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if facets.YearMin != 1999 || facets.YearMax != 2021 {
|
||||||
|
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
|
||||||
|
}
|
||||||
|
if len(facets.Genres) != 2 {
|
||||||
|
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
|
||||||
|
}
|
||||||
|
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
|
||||||
|
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
|
||||||
|
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
|
||||||
|
router, _, _, libID := newLibraryFilterEnv(t)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
if !containsSubstring(string(body), `"genres":[]`) {
|
||||||
|
t.Fatalf("body = %s, want genres:[]", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表筛选:按类型过滤后只返回命中的条目。
|
||||||
|
func TestListMediaAppliesGenreFilter(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||||
|
t.Fatalf("payload = %+v, want only 动作", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
|
||||||
|
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 先拉全量(可能写缓存),再拉筛选结果。
|
||||||
|
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
|
||||||
|
t.Fatalf("unfiltered status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("filtered status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 {
|
||||||
|
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未观看筛选:已看完的不出现,看了一半的仍出现。
|
||||||
|
func TestListMediaUnwatchedFilter(t *testing.T) {
|
||||||
|
router, svc, userID, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
|
||||||
|
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
for _, h := range []*model.PlaybackHistory{
|
||||||
|
{UserID: userID, MediaID: "m-done", Completed: true},
|
||||||
|
{UserID: userID, MediaID: "m-half", Completed: false},
|
||||||
|
} {
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
|
||||||
|
t.Fatalf("items = %+v, want only 看一半", payload.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryRandomReturnsMedia(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := json.Unmarshal(body, &media); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if media.Title != "唯一" {
|
||||||
|
t.Fatalf("title = %q, want 唯一", media.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
|
||||||
|
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d body=%s, want 404", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
|
||||||
|
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// genre[]=Action — axios bracket format without custom paramsSerializer
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||||
|
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
|
||||||
|
func TestLibraryRandomHonoursFilters(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := json.Unmarshal(body, &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "B" {
|
||||||
|
t.Fatalf("title = %q, want B", media.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -32,10 +34,10 @@ type createLibraryReq struct {
|
|||||||
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
|
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
|
||||||
type webLibraryPayload struct {
|
type webLibraryPayload struct {
|
||||||
model.Library
|
model.Library
|
||||||
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
||||||
RemoteSource string `json:"remote_source,omitempty"`
|
RemoteSource string `json:"remote_source,omitempty"`
|
||||||
Total int64 `json:"total,omitempty"`
|
Total int64 `json:"total,omitempty"`
|
||||||
Cards []service.SeriesCard `json:"cards,omitempty"`
|
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
||||||
@@ -60,37 +62,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
role, _ := c.Get(middleware.CtxUserRole)
|
role, _ := c.Get(middleware.CtxUserRole)
|
||||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||||
if !includeHidden {
|
if !includeHidden {
|
||||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||||
visibility := mediaVisibilityForRequest(c, svc)
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
filtered := libs[:0]
|
filtered := libs[:0]
|
||||||
for _, lib := range libs {
|
for _, lib := range libs {
|
||||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||||
filtered = append(filtered, lib)
|
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 len(targetSet) > 0 {
|
libs = filtered
|
||||||
filtered := libs[:0]
|
}
|
||||||
for _, lib := range libs {
|
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||||
if _, ok := targetSet[lib.ID]; ok {
|
var targetSet map[string]struct{}
|
||||||
filtered = append(filtered, lib)
|
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"
|
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||||
limit := 10
|
limit := 10
|
||||||
if withPreview {
|
if withPreview {
|
||||||
@@ -103,49 +105,72 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
out := make([]webLibraryPayload, 0, len(libs)+8)
|
out := make([]webLibraryPayload, 0, len(libs)+8)
|
||||||
if withPreview {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, p := range previews {
|
for _, p := range previews {
|
||||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
out = append(out, webLibraryPayload{
|
||||||
}
|
Library: p.Library,
|
||||||
} else {
|
Total: p.Total,
|
||||||
visibility := mediaVisibilityForRequest(c, svc)
|
Cards: service.NewSeriesCardViews(p.Cards),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
for _, l := range libs {
|
}
|
||||||
var total int64
|
} else {
|
||||||
if counts != nil {
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
total = counts[l.ID]
|
libIDs := make([]string, len(libs))
|
||||||
}
|
for i, l := range libs {
|
||||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
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 约束)。
|
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||||
if svc.EmbyRemote != nil {
|
if includeRemote && svc.EmbyRemote != nil {
|
||||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||||
visibility := mediaVisibilityForRequest(c, svc)
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||||
for _, v := range views {
|
for _, v := range views {
|
||||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(targetSet) > 0 {
|
||||||
|
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||||
continue
|
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))
|
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||||
for i, v := range allowedViews {
|
for i, v := range allowedViews {
|
||||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||||
@@ -170,19 +195,19 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if acct == nil {
|
if acct == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpMount := &model.EmbyMount{
|
tmpMount := &model.EmbyMount{
|
||||||
Base: model.Base{ID: v.MountID},
|
Base: model.Base{ID: v.MountID},
|
||||||
AccountID: v.AccountID,
|
AccountID: v.AccountID,
|
||||||
RemoteViewID: v.RemoteID,
|
RemoteViewID: v.RemoteID,
|
||||||
CollectionType: v.CollectionType,
|
CollectionType: v.CollectionType,
|
||||||
Name: v.Library.Name,
|
Name: v.Library.Name,
|
||||||
}
|
}
|
||||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||||
remotePayloads[i].Total = total
|
remotePayloads[i].Total = total
|
||||||
}
|
}
|
||||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||||
remotePayloads[i].Cards = cards
|
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
@@ -383,6 +408,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
|
||||||
|
//
|
||||||
|
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
|
||||||
|
// 行为与此前完全一致(不引入任何默认筛选)。
|
||||||
|
//
|
||||||
|
// 参数约定:
|
||||||
|
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
|
||||||
|
// - year_min / year_max 年份区间,0 或非法值表示不限
|
||||||
|
// - rating_min 评分下限(浮点)
|
||||||
|
// - unwatched=1 仅显示未看完;用户 ID 取自会话
|
||||||
|
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
|
||||||
|
filters := service.MediaListFilters{
|
||||||
|
Genres: parseRepeatedQueryValues(c, "genre"),
|
||||||
|
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
|
||||||
|
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
|
||||||
|
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
|
||||||
|
}
|
||||||
|
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
|
||||||
|
filters.Unwatched = true
|
||||||
|
filters.UserID = toString(mustSessionUserID(c))
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
|
||||||
|
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
|
||||||
|
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
|
||||||
|
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
|
||||||
|
raw := c.QueryArray(key)
|
||||||
|
if len(raw) == 0 {
|
||||||
|
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
|
||||||
|
raw = c.QueryArray(key + "[]")
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, value := range raw {
|
||||||
|
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
|
||||||
|
for _, part := range strings.Split(value, ",") {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[trimmed]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeInt(raw string) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeFloat(raw string) float64 {
|
||||||
|
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||||
|
if err != nil || value < 0 || math.IsNaN(value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTruthyQuery(raw string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
|
||||||
|
func mustSessionUserID(c *gin.Context) any {
|
||||||
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
@@ -421,9 +532,15 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sortSpec := parseMediaSort(c)
|
||||||
|
var history map[string]time.Time
|
||||||
|
if sortSpec.Field == "last_played" {
|
||||||
|
history = mediaHistoryMap(c, svc)
|
||||||
|
}
|
||||||
|
filters := parseLibraryFilters(c)
|
||||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||||
if !groupVersions {
|
if !groupVersions {
|
||||||
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
@@ -439,17 +556,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||||
|
items := service.PaginateMediaItems(grouped, page, size)
|
||||||
if items == nil {
|
if items == nil {
|
||||||
items = []service.MediaItem{}
|
items = []service.MediaItem{}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": len(grouped),
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": size,
|
"page_size": size,
|
||||||
})
|
})
|
||||||
@@ -563,6 +682,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return remoteItems
|
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") != "" {
|
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||||
@@ -658,7 +810,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
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 err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||||
if !c.Writer.Written() {
|
if !c.Writer.Written() {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -666,25 +820,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
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
|
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)
|
m, err := svc.Media.GetMedia(ctx, id)
|
||||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||||
@@ -694,6 +848,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||||
return
|
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"))
|
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||||
if errors.Is(err, service.ErrMediaNotFound) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
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) {
|
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
|
|||||||
LibraryIDs []string `json:"library_ids"`
|
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 {
|
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import (
|
|||||||
// ─── History ────────────────────────────────────────────────────────────────
|
// ─── History ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type progressReq struct {
|
type progressReq struct {
|
||||||
MediaID string `json:"media_id" binding:"required"`
|
MediaID string `json:"media_id" binding:"required"`
|
||||||
PositionMs int64 `json:"position_ms"`
|
PositionMs int64 `json:"position_ms"`
|
||||||
DurationMs int64 `json:"duration_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 {
|
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
if err := svc.Playback.RecordProgress(
|
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
|
||||||
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
|
UserID: toString(uid),
|
||||||
); err != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
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))
|
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
|||||||
&model.Library{},
|
&model.Library{},
|
||||||
&model.Media{},
|
&model.Media{},
|
||||||
&model.PlayProfile{},
|
&model.PlayProfile{},
|
||||||
|
&model.Series{},
|
||||||
|
&model.MediaSegment{},
|
||||||
|
&model.MediaSegmentFetch{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -408,6 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
|||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||||
api.GET("/playback/:id/info", playbackInfoHandler(svc))
|
api.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||||
|
api.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||||
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||||
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||||
api.GET("/stream/:id", streamHandler(svc))
|
api.GET("/stream/:id", streamHandler(svc))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Package handler — 片头/片尾片段接口。
|
||||||
|
//
|
||||||
|
// GET /playback/:id/segments
|
||||||
|
//
|
||||||
|
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
|
||||||
|
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
|
||||||
|
// 远程 Emby)无关,独立出来对所有媒体一致。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playbackSegmentsHandler returns the skippable ranges for one media item.
|
||||||
|
// The client calls it after playback has already started, so the provider
|
||||||
|
// lookup never delays a play.
|
||||||
|
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
autoSkip := resolveAutoSkipFlag(c, svc)
|
||||||
|
segments := []service.SegmentView{}
|
||||||
|
|
||||||
|
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||||
|
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
||||||
|
if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) {
|
||||||
|
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
|
||||||
|
if listErr != nil && svc.Log != nil {
|
||||||
|
svc.Log.Debug("list media segments failed",
|
||||||
|
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||||
|
}
|
||||||
|
segments = service.ToSegmentViews(rows)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
|
||||||
|
// currently in effect. It reuses selectedPlayProfile so the server agrees with
|
||||||
|
// the UI about which profile is active (explicit header first, then the user's
|
||||||
|
// default profile), and a PIN-locked profile never silently skips for the user.
|
||||||
|
func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
|
||||||
|
profile, locked := selectedPlayProfile(c, svc)
|
||||||
|
if locked || profile == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return profile.SkipIntro
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type segmentPayload struct {
|
||||||
|
Segments []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
StartMs int64 `json:"start_ms"`
|
||||||
|
EndMs int64 `json:"end_ms"`
|
||||||
|
} `json:"segments"`
|
||||||
|
AutoSkip bool `json:"auto_skip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
|
||||||
|
|
||||||
|
func TestPlaybackSegmentsReturnsProviderDataAndAutoSkip(t *testing.T) {
|
||||||
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
|
// 片段数据与播放来源无关,云盘媒体同样适用,只要它能解析出外部 ID。
|
||||||
|
// 注意列名是 tm_db_id(GORM 对 TMDbID 的默认命名)。
|
||||||
|
if err := svc.Repo.DB.Model(&model.Media{}).
|
||||||
|
Where("id = ?", "media-1").Update("tm_db_id", 27205).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls int32
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(segmentsProviderBody))
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo).
|
||||||
|
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
|
||||||
|
|
||||||
|
// 默认档案打开「自动跳过片头」,接口应把开关原样带出来。
|
||||||
|
if err := svc.Repo.DB.Create(&model.PlayProfile{
|
||||||
|
Base: model.Base{ID: "profile-1"},
|
||||||
|
UserID: "user-1",
|
||||||
|
Name: "主档案",
|
||||||
|
IsDefault: true,
|
||||||
|
SkipIntro: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginToken := signedTestToken(t, secret)
|
||||||
|
fetch := func() segmentPayload {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload segmentPayload
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
first := fetch()
|
||||||
|
if !first.AutoSkip {
|
||||||
|
t.Fatal("auto_skip should reflect the active profile's skip_intro switch")
|
||||||
|
}
|
||||||
|
if len(first.Segments) != 2 {
|
||||||
|
t.Fatalf("segments = %#v, want 2", first.Segments)
|
||||||
|
}
|
||||||
|
// start_ms: null -> 0;end_ms: null -> 0(延续到片尾,由客户端按时长补齐)。
|
||||||
|
if first.Segments[0].Kind != "intro" || first.Segments[0].StartMs != 0 || first.Segments[0].EndMs != 38_000 {
|
||||||
|
t.Fatalf("intro segment = %#v", first.Segments[0])
|
||||||
|
}
|
||||||
|
if first.Segments[1].Kind != "credits" || first.Segments[1].StartMs != 6_480_000 || first.Segments[1].EndMs != 0 {
|
||||||
|
t.Fatalf("credits segment = %#v", first.Segments[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次播放必须走本地缓存,不再打外网。
|
||||||
|
if second := fetch(); len(second.Segments) != 2 {
|
||||||
|
t.Fatalf("second fetch segments = %#v", second.Segments)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Fatalf("provider calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackSegmentsAutoSkipIsFalseWithoutProfile(t *testing.T) {
|
||||||
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
|
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload segmentPayload
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if payload.AutoSkip {
|
||||||
|
t.Fatal("auto_skip must default to false")
|
||||||
|
}
|
||||||
|
// 即使一条片段都没有,也必须返回空数组而不是 null,前端才能无条件遍历。
|
||||||
|
if payload.Segments == nil {
|
||||||
|
t.Fatal("segments must serialise as an empty array, not null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackSegmentsForUnknownMediaReturnsNotFound(t *testing.T) {
|
||||||
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
|
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/does-not-exist/segments", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d body=%s, want 404", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
|||||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||||
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||||
|
// 设备代管:管理员可查看并踢掉任意用户的设备。
|
||||||
|
admin.GET("/users/:id/devices", adminUserDevicesHandler(svc))
|
||||||
|
admin.POST("/users/:id/devices/kick-all", adminKickAllUserDevicesHandler(svc))
|
||||||
|
admin.POST("/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||||
admin.GET("/settings", listSettingsHandler(svc))
|
admin.GET("/settings", listSettingsHandler(svc))
|
||||||
admin.PUT("/settings", updateSettingHandler(svc))
|
admin.PUT("/settings", updateSettingHandler(svc))
|
||||||
|
admin.POST("/telegram/test", testTelegramHandler(svc))
|
||||||
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
||||||
admin.GET("/logs", recentLogsHandler(svc))
|
admin.GET("/logs", recentLogsHandler(svc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,23 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
|||||||
authed.PATCH("/me", updateProfileHandler(svc))
|
authed.PATCH("/me", updateProfileHandler(svc))
|
||||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(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/password", changePasswordHandler(svc))
|
||||||
authed.POST("/me/logout", logoutHandler(svc))
|
authed.POST("/me/logout", logoutHandler(svc))
|
||||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||||
|
|
||||||
|
// 设备管理:路由挂在 /me 下,用户 ID 一律取自会话,天然只能管自己的设备。
|
||||||
|
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||||
|
|
||||||
|
// Telegram 通知绑定:一次性码 + Bot /bind <code>。
|
||||||
|
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||||
|
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||||
|
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||||
|
|
||||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
|||||||
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||||
|
|
||||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||||
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
||||||
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
||||||
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
||||||
@@ -44,6 +58,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
|||||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||||
authed.GET("/media/:id", getMediaHandler(svc))
|
authed.GET("/media/:id", getMediaHandler(svc))
|
||||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(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.GET("/media", searchMediaHandler(svc))
|
||||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||||
@@ -54,6 +70,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
|
|||||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||||
|
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
|
||||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||||
}
|
}
|
||||||
@@ -64,6 +81,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
|
|||||||
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||||
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
||||||
authed.DELETE("/hls/:id", stopTranscodeHandler(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))
|
authed.GET("/img", imageProxyHandler(svc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
|||||||
|
|
||||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||||
|
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||||
|
|
||||||
authed.GET("/watch-history", historyListHandler(svc))
|
authed.GET("/watch-history", historyListHandler(svc))
|
||||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
|
||||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||||
@@ -72,6 +73,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
|
|||||||
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||||
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
|
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
|
||||||
|
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
|||||||
"GET /api/me",
|
"GET /api/me",
|
||||||
"GET /api/me/pinned-libraries",
|
"GET /api/me/pinned-libraries",
|
||||||
"PUT /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/auth/permissions",
|
||||||
"GET /api/libraries",
|
"GET /api/libraries",
|
||||||
"GET /api/media",
|
"GET /api/media",
|
||||||
@@ -34,6 +36,7 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
|||||||
"GET /api/storage",
|
"GET /api/storage",
|
||||||
"GET /api/watch-history",
|
"GET /api/watch-history",
|
||||||
"GET /api/playback/:id/info",
|
"GET /api/playback/:id/info",
|
||||||
|
"GET /api/playback/:id/segments",
|
||||||
} {
|
} {
|
||||||
if !routes[want] {
|
if !routes[want] {
|
||||||
t.Fatalf("%s route is not registered", want)
|
t.Fatalf("%s route is not registered", want)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
libID := c.Param("id")
|
libID := c.Param("id")
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
sortSpec := parseMediaSort(c)
|
||||||
|
var history map[string]time.Time
|
||||||
|
if sortSpec.Field == "last_played" {
|
||||||
|
history = mediaHistoryMap(c, svc)
|
||||||
|
}
|
||||||
// 远程剧集库:远程 Series 映射为系列卡片。
|
// 远程剧集库:远程 Series 映射为系列卡片。
|
||||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
|
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
|
||||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
|
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
|
||||||
@@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cards = sortRemoteSeriesCards(cards, sortSpec, history)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -117,11 +124,14 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
|
items, total, err := svc.Media.ListLibrarySeriesCardsFiltered(
|
||||||
|
c.Request.Context(), libID, mediaVisibilityForRequest(c, svc), parseLibraryFilters(c),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||||
if page < 1 {
|
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()})
|
||||||
|
}
|
||||||
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
acct.LastTestAt = &now
|
|
||||||
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
|
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
|
||||||
|
result := ""
|
||||||
|
ok := false
|
||||||
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
|
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
|
||||||
acct.LastTestResult = err.Error()
|
result = err.Error()
|
||||||
acct.LastTestOK = false
|
|
||||||
} else {
|
} else {
|
||||||
acct.LastTestResult = "ok"
|
result = "ok"
|
||||||
acct.LastTestOK = true
|
ok = true
|
||||||
}
|
}
|
||||||
} else {
|
acct.LastTestAt = &now
|
||||||
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
|
acct.LastTestResult = result
|
||||||
if acct == nil {
|
acct.LastTestOK = ok
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
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
|
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])
|
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||||
return
|
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])
|
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/retry-failed",
|
||||||
"POST /api/admin/strm/uploads/cancel-pending",
|
"POST /api/admin/strm/uploads/cancel-pending",
|
||||||
"GET /api/strm/play/:provider/:file",
|
"GET /api/strm/play/:provider/:file",
|
||||||
|
"HEAD /api/strm/play/:provider/:file",
|
||||||
} {
|
} {
|
||||||
if !routes[want] {
|
if !routes[want] {
|
||||||
t.Fatalf("%s route is not registered", want)
|
t.Fatalf("%s route is not registered", want)
|
||||||
@@ -108,4 +112,27 @@ func TestStrmAccountsCRUD(t *testing.T) {
|
|||||||
if w.Code == http.StatusInternalServerError {
|
if w.Code == http.StatusInternalServerError {
|
||||||
t.Fatalf("strm play endpoint errored: %d", w.Code)
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||