Compare commits

...

60 Commits

Author SHA1 Message Date
truewhile be9d3a6d9e 优化 2026-09-13 22:00:50 +08:00
truewhile b8e48a1f45 优化 2026-09-13 21:23:06 +08:00
truewhile 670dd90dbe 优化 2026-09-13 21:03:06 +08:00
truewhile 9a68ef701f 重构核心模块并同步更新项目配置与相关实现 2026-09-13 20:36:14 +08:00
truewhile 386fc358d1 Add configurable memory limits and LRU eviction for runtime cache 2026-09-13 00:41:04 +08:00
truewhile 2989649445 Add Emby-compatible request logging and ordered playback progress 2026-09-12 23:06:44 +08:00
truewhile 5ddca9a1f6 支持 STRM 播放端点的 HEAD 请求 2026-09-12 22:31:30 +08:00
truewhile e337d9932b Fix Emby person image routing and search dropdown dismissal 2026-09-12 16:55:53 +08:00
truewhile df654ea47b Add TMDb-backed Emby people metadata and image caching 2026-09-12 16:31:57 +08:00
truewhile 41fe75e136 支持 Emby 演职人员头像代理并优化媒体系列搜索 2026-09-12 16:07:16 +08:00
truewhile e65accf2cd Optimize Emby caching and image resize concurrency 2026-09-12 12:46:30 +08:00
truewhile f54ef2228c Merge pull request #32 from truewhile/cursor/fix-emby-hero-backdrop
修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
2026-09-12 11:47:38 +08:00
truewhile 3c2bfb14ca 修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:43:51 +08:00
truewhile 8f64f961ac 缓存弹幕抓取结果并合并并发请求 2026-09-12 11:02:24 +08:00
truewhile 6797394c5b Serve font assets with caching and test missing-font handling 2026-09-12 10:33:51 +08:00
truewhile e71d9d61d1 优先使用刮削元数据匹配弹幕剧集 2026-09-12 10:27:55 +08:00
truewhile 1ff60d5641 完善应用功能与界面交互 2026-09-12 01:00:19 +08:00
truewhile 3f361631d0 修复搜索下拉框异步响应导致的意外展开 2026-09-11 21:30:30 +08:00
truewhile cfe0c0cf28 Preserve media metadata across STRM path renames 2026-09-11 20:45:54 +08:00
truewhile d6355d5582 Keep TV episodes as mixed-series representatives 2026-09-11 19:52:49 +08:00
truewhile 0f06bdf929 支持 ASS 字幕原样渲染与字幕显示设置 2026-09-11 19:35:08 +08:00
truewhile e840cbd2c9 处理bug 2026-09-11 15:35:02 +08:00
truewhile 2bb3610da7 添加繁体转简体,简体转繁体功能 2026-09-11 15:14:24 +08:00
truewhile 9ca1b28cb4 优化 2026-09-10 22:30:17 +08:00
truewhile 9effb1422b 优化 2026-09-10 22:06:28 +08:00
truewhile b855e00345 优化 2026-09-10 16:58:41 +08:00
truewhile 8f2551a1b6 优化排序 2026-09-09 22:24:45 +08:00
truewhile 18e6cb40fd 完善动漫特别内容分类与版本判定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:03:52 +08:00
truewhile 92e693ccac 优化 2026-09-09 20:32:03 +08:00
truewhile 62206aa05a 优化 2026-09-09 19:26:22 +08:00
truewhile f9dd082159 优化动漫刮削 2026-09-09 17:36:11 +08:00
truewhile d489c1608e 优化strm多版本显示 2026-09-09 17:07:21 +08:00
truewhile 1ea29cca9e bug处理 2026-09-09 00:39:00 +08:00
truewhile ec5f81e6cb bug处理 2026-09-09 00:18:04 +08:00
truewhile 8eb017fedc 优化 2026-09-09 00:03:14 +08:00
truewhile fb4e762026 优化 2026-09-08 23:44:20 +08:00
truewhile 4239474100 优化 2026-09-08 23:11:57 +08:00
truewhile 8225b4e319 优化 2026-09-08 23:05:07 +08:00
truewhile c086b01381 优化 2026-09-08 22:44:36 +08:00
truewhile f196e5621f 优化 2026-09-08 22:12:26 +08:00
truewhile 13d60b3bd9 分集图片设置落库 2026-09-08 22:00:06 +08:00
truewhile 3d30f6f27c bug处理 2026-09-08 21:08:47 +08:00
truewhile bd02e194ff 优化 2026-09-08 20:52:35 +08:00
truewhile 1c9b742c8d 优化 2026-09-08 20:24:45 +08:00
truewhile 20e7d7fa99 bug处理 2026-09-08 18:12:52 +08:00
truewhile 394ea0382e 优化 2026-09-08 09:46:02 +08:00
truewhile 4d698d2757 优化播放 2026-09-08 09:24:29 +08:00
truewhile fdfb2ee987 bug处理 2026-09-08 08:41:01 +08:00
truewhile b23b1d728e bug处理 2026-09-08 00:36:56 +08:00
truewhile ad427fc0d2 bug处理 2026-09-08 00:10:25 +08:00
truewhile c489374b61 bug处理 2026-09-07 23:45:59 +08:00
truewhile 5a47e6f52d bug处理 2026-09-07 23:21:24 +08:00
truewhile c4b8114115 优化转码逻辑 2026-09-07 23:10:48 +08:00
truewhile 0e592105c4 fix(web): STRM 直连失败后回退 HLS 转码
fix(web): STRM 直连失败后回退 HLS 转码
2026-09-07 22:56:29 +08:00
truewhile f20c9c3234 fix(web): STRM 直连失败后回退 HLS 转码
不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 22:52:54 +08:00
truewhile 38b85bb487 feat(strm): 同名多版本择优 / keep_ext,并支持合并展示与播放切换 (#30)
* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换

默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕

NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm
与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt;
仍兼容历史上的 name.mkv.nfo 命名。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(emby): People NFO 按共享词干匹配 keep_ext strm

resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo),
同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 13:56:30 +08:00
truewhile 6bf8a6878a fix(strm): 115 元数据上传幂等,避免重复上传 (#29)
上传/重试前按父目录+文件名+SHA1 探活,已有同内容副本则跳过并清脏副本;
成功后回写新 file_id;同步扫描认可 30 分钟内同路径同大小的 done 任务,
缩短「任务已完成但列表未反映」导致的再入队窗口。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 09:01:58 +08:00
truewhile 2e636ce43f feat(web): 支持生成电视端临时登录码
- 新增创建临时登录密码的 API 请求
- 新增电视端临时登录密码弹窗组件并在用户菜单中提供入口
- 在个人资料页面添加电视临时登录码生成与倒计时展示
2026-09-06 23:22:34 +08:00
truewhile 28aa466107 feat: 支持临时登录密码并完善 Emby 媒体详情兼容
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录
- 媒体及剧集详情补充 People 演职员信息解析与返回
- 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑
- 增加临时密码登录相关的单元测试
2026-09-06 22:58:41 +08:00
truewhile f1b8010496 bug处理+emby客户端兼容 2026-09-06 22:10:23 +08:00
283 changed files with 19713 additions and 2381 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"ssh": {
"command": "cmd.exe",
"args": [
"/c",
"npx",
"-y",
"@aiondadotcom/mcp-ssh"
],
"env": {
"ProgramData": "C:\\ProgramData"
}
}
}
}
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
+38
View File
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
}
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
if err != nil {
return nil, nil, err
}
log := zap.New(
zapcore.NewCore(encoder, writer, zap.InfoLevel),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
return log, func() { _ = writer.Close() }, nil
}
func embyCompatLogPath(cfg *config.Config) string {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".emby-compat" + ext
}
return filepath.Join(out, "emby-compat.log")
}
+31
View File
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
}
_ = writer.Sync()
}
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newEmbyCompatLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("emby request", zap.String("client", "Infuse"))
_ = log.Sync()
closeFn()
path := filepath.Join(dir, "logs", "emby-compat.log")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
t.Fatalf("dedicated Emby log missing request data: %s", data)
}
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
}
}
+11 -1
View File
@@ -59,11 +59,21 @@ func main() {
}
defer func() { _ = logger.Sync() }()
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
if err != nil {
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
}
defer func() {
_ = embyCompatLogger.Sync()
closeEmbyCompatLogger()
}()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
)
// Ensure data / cache / web dirs exist.
@@ -104,7 +114,7 @@ func main() {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, services)
router := buildRouter(cfg, logger, embyCompatLogger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
+29
View File
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
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 {
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 {
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 {
t.Fatal(err)
}
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
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)
brandResp := httptest.NewRecorder()
router.ServeHTTP(brandResp, brandReq)
+14 -1
View File
@@ -19,13 +19,16 @@ import (
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
@@ -59,6 +62,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
c.Next()
})
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.Use(func(c *gin.Context) {
setNoCacheHeaders(c)
@@ -71,6 +81,9 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
+25 -18
View File
@@ -11,42 +11,48 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metatube-community/metatube-sdk-go v1.4.0
github.com/redis/go-redis/v9 v9.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.30.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -58,6 +64,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -79,14 +86,14 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
+83 -41
View File
@@ -4,8 +4,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
@@ -20,22 +20,26 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47/go.mod h1:bIH3W2QoHvchgAPl6DUwIUguIHJwvMS6sePQIvhxPTc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
@@ -48,8 +52,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -58,20 +62,25 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -92,6 +101,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/metatube-community/metatube-sdk-go v1.4.0 h1:NBbjxU5SuzgMABM58/f/tmbuTg+hn+Nv1PnlF+gihGI=
github.com/metatube-community/metatube-sdk-go v1.4.0/go.mod h1:FRgPioGhlkXgD6RmOKQDWITwxlsl0K9cXONFz06jymw=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -99,6 +110,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -112,11 +125,10 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -173,28 +185,38 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -203,15 +225,35 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+6 -2
View File
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
+3
View File
@@ -7,6 +7,7 @@ const (
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
DefaultCacheMemoryMaxSizeMB = 128
)
func setDefaults(v *viper.Viper) {
@@ -44,10 +45,12 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+3
View File
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mebox"
}
+10 -3
View File
@@ -113,10 +113,12 @@ type LoggingConfig struct {
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig 控制磁盘转码/刮削缓存。
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
@@ -124,6 +126,11 @@ type CacheConfig struct {
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 {
+3
View File
@@ -68,6 +68,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
}
c.Status(http.StatusNoContent)
}
}
+20 -1
View File
@@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
c.Status(http.StatusNoContent)
}
}
func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, ok := c.Get(middleware.CtxUserID)
if !ok || uid == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"expires_in": expireSec,
})
}
}
+37 -2
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -14,7 +15,13 @@ import (
// specific danmaku library chosen by the user after a disambiguation.
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
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{
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
}
res, err := svc.Danmaku.FetchWithOptions(
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -28,6 +35,34 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
// admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
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 持久化当前用户的弹幕偏好。目前只有合并开关,
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
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 struct {
MergeSources *bool `json:"merge_sources"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if req.MergeSources == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
return
}
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
}
}
+135
View File
@@ -0,0 +1,135 @@
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")
}
}
+30 -1
View File
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
c.Query("X-MediaBrowser-Client"),
auth["Client"],
auth["client"],
),
}
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
return "Emby"
case strings.Contains(lower, "jellyfin"):
return "Jellyfin"
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
return "CapyPlayer"
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
return "SenPlayer"
case strings.Contains(lower, "yamby"):
return "Yamby"
case strings.Contains(lower, "vidhub"):
return "VidHub"
case strings.Contains(lower, "fileball"):
return "Fileball"
case strings.Contains(lower, "hamhub"):
return "HamHub"
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
return "AfuseKt"
case strings.Contains(lower, "cony"):
return "Cony"
case strings.Contains(lower, "kodi"):
return "Kodi"
case strings.Contains(lower, "mrmc"):
return "MrMC"
case strings.Contains(lower, "forward"):
return "Forward"
case strings.Contains(lower, "alpha"):
return "Alpha"
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
return "DanDanPlay"
case strings.Contains(lower, "potplayer"):
return "PotPlayer"
case strings.Contains(lower, "vlc"):
return "VLC"
case strings.Contains(lower, "hills"):
return "Hills"
default:
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
return "Mac"
case strings.Contains(lower, "linux"):
return "Linux PC"
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
return "Apple TV"
default:
return ""
@@ -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,594 @@
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/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestNormalizeEmbyPath(t *testing.T) {
tests := []struct {
input string
wantPath string
changed bool
}{
{
input: "/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/emby/items/123/playbackInfo",
wantPath: "/emby/items/123/playbackinfo",
changed: true,
},
{
input: "//emby//System//Info//Public",
wantPath: "/emby/system/info/public",
changed: true,
},
{
input: "/Items/msgo-series-1/PlaybackInfo",
wantPath: "/items/msgo-series-1/playbackinfo",
changed: true,
},
{
input: "/Videos/m-123/Master.m3u8",
wantPath: "/videos/m-123/master.m3u8",
changed: true,
},
{
input: "/api/unknown/other",
wantPath: "/api/unknown/other",
changed: false,
},
}
for _, tt := range tests {
gotPath, changed := NormalizeEmbyPath(tt.input)
if gotPath != tt.wantPath || changed != tt.changed {
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
}
}
}
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
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{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "ServerName") {
t.Fatalf("expected server info body, got: %s", w.Body.String())
}
}
func TestEmbyMixedCaseHandling(t *testing.T) {
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{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 混合大小写驼峰: /emby/system/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
}
}
func TestEmbyClientIdentification(t *testing.T) {
tests := []struct {
name string
ua string
query string
headerAuth string
wantClient string
}{
{
name: "CapyPlayer via UA",
ua: "CapyPlayer/1.2.0 (iOS)",
wantClient: "CapyPlayer",
},
{
name: "SenPlayer via UA",
ua: "SenPlayer/2.1",
wantClient: "SenPlayer",
},
{
name: "Fileball via UA",
ua: "Fileball/1.0.0",
wantClient: "Fileball",
},
{
name: "Kodi via UA",
ua: "Kodi/20.2",
wantClient: "Kodi",
},
{
name: "Client in query",
ua: "CustomApp/1.0",
query: "?X-Emby-Client=CapyPlayer",
wantClient: "CapyPlayer",
},
{
name: "Client in auth header",
ua: "Custom/1.0",
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
wantClient: "SenPlayer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
url := "/test"
if tt.query != "" {
url += tt.query
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
if tt.headerAuth != "" {
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
}
c.Request = req
info := embyClientInfoFromRequest(c)
if info.Client != tt.wantClient {
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
}
})
}
}
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
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)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, svc)
token := signedTestToken(t, secret)
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)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "m-adaptive-1"},
Title: "测试媒体",
Path: "D:\\media\\test.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
// 1. 从 /emby 前缀请求 PlaybackInfo
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req1.Header.Set("X-Emby-Token", token)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
}
var res1 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
}
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req2.Header.Set("X-Emby-Token", token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
}
var res2 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
}
// 3. 从根路径 /Items 请求 PlaybackInfo
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
req3.Header.Set("X-Emby-Token", token)
w3 := httptest.NewRecorder()
router.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
}
}
func TestEmbyImageClearNoStore(t *testing.T) {
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)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("placeholder image should return 200, got %d", w.Code)
}
cacheControl := w.Header().Get("Cache-Control")
if strings.Contains(cacheControl, "no-store") {
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
}
if !strings.Contains(cacheControl, "public") {
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
}
}
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
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)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
embySvc := service.NewEmbyService(nil, nil, repos)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
Emby: embySvc,
}
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
if err != nil {
t.Fatalf("register: %v", err)
}
// 1. 生成 6 位纯数字临时密码 (OTP)
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
if err != nil {
t.Fatalf("create temp password: %v", err)
}
if len(code) != 6 || expireSec <= 0 {
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
}
router := gin.New()
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
// 2. 使用临时密码在 Emby 接口登录
body := `{"Username":"tvuser","Pw":"` + code + `"}`
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
}
var loginResp struct {
AccessToken string `json:"AccessToken"`
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
}
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
t.Fatalf("unmarshal login resp: %v", err)
}
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
t.Fatalf("unexpected login payload: %#v", loginResp)
}
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
}
}
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
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)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret-compat"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
embySvc := service.NewEmbyService(nil, nil, repos)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
Emby: embySvc,
}
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
if err != nil {
t.Fatalf("register: %v", err)
}
token, err := authSvc.IssueEmbyToken(user)
if err != nil {
t.Fatalf("issue token: %v", err)
}
// 创建 TV Library
lib := &model.Library{
Name: "电视剧",
Path: "/media/电视剧",
Type: "tv",
}
lib.ID = "lib-tv-1"
if err := db.Create(lib).Error; err != nil {
t.Fatalf("create lib: %v", err)
}
// 创建 Series
series := &model.Series{
LibraryID: "lib-tv-1",
Title: "Test Drama",
PosterURL: "https://example.com/series_poster.jpg",
BackdropURL: "https://example.com/series_backdrop.jpg",
}
series.ID = "s-test-1"
if err := db.Create(series).Error; err != nil {
t.Fatalf("create series: %v", err)
}
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
ep := &model.Media{
LibraryID: "lib-tv-1",
SeriesID: "s-test-1",
Title: "Test Episode 1",
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
SeasonNum: 1,
EpisodeNum: 1,
DurationSec: 0, // 未知时长
PosterURL: "https://example.com/ep1_still.jpg",
}
ep.ID = "ep-test-1"
if err := db.Create(ep).Error; err != nil {
t.Fatalf("create ep: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
// 1. 获取 Series 详情
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
reqSeries.Header.Set("X-Emby-Token", token)
wSeries := httptest.NewRecorder()
router.ServeHTTP(wSeries, reqSeries)
if wSeries.Code != http.StatusOK {
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
}
var seriesPayload map[string]any
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
}
if _, ok := seriesPayload["People"]; !ok {
t.Fatalf("series payload should include People array")
}
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
// 添加一条播放进度记录 (posMs = 60000)
hist := &model.PlaybackHistory{
UserID: user.ID,
MediaID: ep.ID,
PositionMs: 60000,
}
_ = db.Create(hist).Error
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
reqEp.Header.Set("X-Emby-Token", token)
wEp := httptest.NewRecorder()
router.ServeHTTP(wEp, reqEp)
if wEp.Code != http.StatusOK {
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
}
var epPayload map[string]any
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
t.Logf("epPayload: %#v", epPayload)
// 验证图片继承
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
}
if epPayload["ParentBackdropItemId"] != "s-test-1" {
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
}
if _, ok := epPayload["People"]; !ok {
t.Fatalf("ep payload should include People array")
}
// 验证 RunTimeTicks 兜底
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
if runTimeTicks <= 0 {
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
}
userData, _ := epPayload["UserData"].(map[string]any)
playedPct, _ := userData["PlayedPercentage"].(float64)
if playedPct <= 0 {
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
}
}
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
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)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret-temp"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
svc := &service.Container{
Repo: repos,
Auth: authSvc,
Token: tokenSvc,
}
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
if err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
api := router.Group("/api")
authed := api.Group("")
authed.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
registerAuthedUserAndLicenseRoutes(authed, svc)
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
}
var resp struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal resp: %v", err)
}
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
t.Fatalf("invalid temp password resp: %#v", resp)
}
// 验证使用生成的临时密码能登录
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
t.Fatalf("login with temp pass failed: %v", err)
}
_ = tokens
}
+75 -14
View File
@@ -29,35 +29,96 @@ var embyPlaceholderPNG = []byte{
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
}
}
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
}
}
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
if svc == nil || svc.Emby == nil {
embyServePlaceholderImage(c)
return
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
}
// 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
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
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
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
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) {
c.Writer.Header().Del("Cache-Control")
c.Writer.Header().Del("Pragma")
c.Writer.Header().Del("Expires")
}
func embyServePlaceholderImage(c *gin.Context) {
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Cache-Control", "public, max-age=86400")
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
+4 -1
View File
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
nil,
)
wProgress := httptest.NewRecorder()
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
t.Fatalf("unexpected playback session metadata: %#v", hist)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
+32
View File
@@ -0,0 +1,32 @@
package handler
import "testing"
func TestIsEmbyPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{path: "/emby", want: true},
{path: "/emby/System/Info/Public", want: true},
{path: "//emby//Items//1", want: true},
{path: "/System/Info/Public", want: true},
{path: "/Search/Hints", want: true},
{path: "/Playback/BitrateTest", want: true},
{path: "/api/unknown", want: false},
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
{path: "/random/path", want: false},
}
for _, tt := range tests {
if got := IsEmbyPath(tt.path); got != tt.want {
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
if !changed || got != "/emby/api/stream/123" {
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
}
}
+276
View File
@@ -0,0 +1,276 @@
package handler
import (
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var (
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
)
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
var embyStaticSegments = map[string]struct{}{
"system": {},
"info": {},
"public": {},
"endpoint": {},
"configuration": {},
"ping": {},
"users": {},
"useritems": {},
"me": {},
"authenticatebyname": {},
"items": {},
"counts": {},
"latest": {},
"resume": {},
"playbackinfo": {},
"shows": {},
"seasons": {},
"episodes": {},
"nextup": {},
"upcoming": {},
"similar": {},
"thumbnailset": {},
"thememedia": {},
"specialfeatures": {},
"intros": {},
"videos": {},
"stream": {},
"subtitles": {},
"master.m3u8": {},
"main.m3u8": {},
"sessions": {},
"playing": {},
"progress": {},
"stopped": {},
"capabilities": {},
"full": {},
"logout": {},
"views": {},
"library": {},
"mediafolders": {},
"virtualfolders": {},
"selectablemediafolders": {},
"branding": {},
"css": {},
"localization": {},
"options": {},
"cultures": {},
"customcssjs": {},
"scripts": {},
"displaypreferences": {},
"quickconnect": {},
"enabled": {},
"startup": {},
"complete": {},
"favoriteitems": {},
"playeditems": {},
"images": {},
"primary": {},
"backdrop": {},
"banner": {},
"thumb": {},
"logo": {},
"serverdomains": {},
"ext": {},
"danmu": {},
"raw": {},
"mediasegments": {},
"artists": {},
"persons": {},
"genres": {},
"embywebsocket": {},
}
var embyRootSegments = map[string]struct{}{
"albums": {},
"artists": {},
"audio": {},
"audiocodecs": {},
"auth": {},
"branding": {},
"channels": {},
"collections": {},
"connect": {},
"containers": {},
"devices": {},
"displaypreferences": {},
"dlna": {},
"encoding": {},
"environment": {},
"gamegenres": {},
"games": {},
"genres": {},
"images": {},
"items": {},
"libraries": {},
"library": {},
"livestreams": {},
"livetv": {},
"localization": {},
"movies": {},
"musicgenres": {},
"news": {},
"notification": {},
"notifications": {},
"officialratings": {},
"packages": {},
"persons": {},
"playback": {},
"playlists": {},
"plugins": {},
"providers": {},
"reports": {},
"scheduledtasks": {},
"search": {},
"sessions": {},
"shows": {},
"songs": {},
"studios": {},
"subtitlecodecs": {},
"sync": {},
"system": {},
"tags": {},
"trailers": {},
"user_usage_stats": {},
"users": {},
"videocodecs": {},
"videos": {},
"years": {},
}
func isEmbyRootSegment(segment string) bool {
if _, ok := embyRootSegments[segment]; ok {
return true
}
_, ok := embyStaticSegments[segment]
return ok
}
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
// 例如 /System/Info/Public、/Users/Public、/Sessions。
func IsEmbyPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
path = multipleSlashesRE.ReplaceAllString(path, "/")
lower := strings.ToLower(path)
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return false
}
return isEmbyRootSegment(strings.ToLower(segments[0]))
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
func NormalizeEmbyPath(p string) (string, bool) {
if p == "" {
return "", false
}
original := p
// 1. 折叠多余斜杠
p = multipleSlashesRE.ReplaceAllString(p, "/")
// 2. 折叠重复的 /emby 前缀
for {
lower := strings.ToLower(p)
if strings.HasPrefix(lower, "/emby/emby/") {
p = "/emby/" + p[len("/emby/emby/"):]
continue
}
if lower == "/emby/emby" {
p = "/emby"
break
}
break
}
// 3. 分析是否具有 Emby 路由特征
hasEmbyPrefix := false
workPath := p
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
hasEmbyPrefix = true
workPath = workPath[len("/emby"):]
} else if strings.EqualFold(workPath, "/emby") {
return "/emby", original != "/emby"
}
segments := strings.Split(strings.Trim(workPath, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return p, p != original
}
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
// 4. 将已知静态段转为小写,动态段保留原样
for i, seg := range segments {
segLower := strings.ToLower(seg)
if _, isStatic := embyStaticSegments[segLower]; isStatic {
if seg != segLower {
segments[i] = segLower
}
}
}
var builder strings.Builder
if hasEmbyPrefix {
builder.WriteString("/emby")
}
for _, seg := range segments {
builder.WriteString("/")
builder.WriteString(seg)
}
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
builder.WriteString("/")
}
normalized := builder.String()
return normalized, normalized != original
}
const embyNormalizedCtxKey = "emby_normalized_path"
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
// 若成功分发并处理,返回 true;否则返回 false。
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
if c == nil || r == nil {
return false
}
if c.GetBool(embyNormalizedCtxKey) {
return false
}
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
if !changed {
return false
}
c.Set(embyNormalizedCtxKey, true)
c.Request.URL.Path = normalized
// 重置 context 状态并由 engine 重新查找路由树
c.Params = nil
c.Writer.Header().Del("Content-Type")
r.HandleContext(c)
return true
}
@@ -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))
}
}
+8 -1
View File
@@ -19,6 +19,8 @@ type embyPlayingReq struct {
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
PlaySessionID string `json:"PlaySessionId"`
PlaySession string `json:"playSessionId"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
playSessionID := embyFirstNonEmptyString(
req.PlaySessionID,
req.PlaySession,
firstQueryValue(c, "PlaySessionId", "playSessionId"),
)
if itemID == "" {
c.Status(http.StatusOK)
return
@@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
if err := svc.Emby.RecordProgressWithSession(c.Request.Context(), uid, itemID, pos, runTime, playSessionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+33
View File
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
r.NoRoute(func(c *gin.Context) {
if TryHandleEmbyNormalizedRoute(c, r) {
return
}
c.Status(404)
})
}
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
@@ -107,12 +115,31 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
// 图片公开(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/:index", embyItemImageHandler(svc))
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
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) {
@@ -194,6 +221,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
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.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
+12 -5
View File
@@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
embyError(c, http.StatusBadRequest, "missing username or password")
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
// 支持电视端/客户端一次性 6 位临时密码登录 (OTP)
if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil {
resp = tempResp
err = nil
}
}
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
clientInfo := embyClientInfoFromRequest(c)
if svc.Sessions != nil {
+1
View File
@@ -24,6 +24,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+145 -85
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -60,37 +61,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
libs = filtered
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
@@ -103,7 +104,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
var previews []service.LibraryPreviewItem
if c.Query("include_total") == "0" {
previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
} else {
previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -111,41 +117,55 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
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
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
// 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短
// 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。
includeRemote := true
if len(targetSet) > 0 {
includeRemote = false
for id := range targetSet {
if service.IsEmbyRemoteID(id) {
includeRemote = true
break
}
}
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if includeRemote && svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
}
allowedViews = append(allowedViews, v)
}
allowedViews = append(allowedViews, v)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
@@ -170,13 +190,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if acct == nil {
return
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
@@ -421,9 +441,14 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -439,17 +464,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
}
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
items := service.PaginateMediaItems(grouped, page, size)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"total": len(grouped),
"page": page,
"page_size": size,
})
@@ -488,7 +515,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
m, err := svc.Media.GetMediaItem(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -497,7 +524,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
if !mediaVisibleForRequest(c, svc, &m.Media) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
@@ -563,6 +590,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return remoteItems
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
@@ -666,25 +726,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func parseMediaSort(c *gin.Context) service.MediaSortSpec {
if c == nil {
return service.NormalizeMediaSort("", "")
}
return service.NormalizeMediaSort(c.Query("sort"), c.Query("order"))
}
// mediaHistoryMap loads the current user's latest playback time per media ID.
// It is only used for the explicit last_played sort so normal pagination keeps
// its single-query path.
func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time {
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil {
return nil
}
uid, _ := c.Get(middleware.CtxUserID)
userID := strings.TrimSpace(toString(uid))
if userID == "" {
return nil
}
var rows []struct {
MediaID string `gorm:"column:media_id"`
WatchedAt time.Time `gorm:"column:watched_at"`
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.PlaybackHistory{}).
Select("media_id, MAX(watched_at) AS watched_at").
Where("user_id = ?", userID).
Group("media_id").
Scan(&rows).Error; err != nil {
return nil
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
out[row.MediaID] = row.WatchedAt
}
return out
}
+83
View File
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
}
}
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
now := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
},
{
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
},
{
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
},
{
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
TMDbID: 503314,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "user")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []model.Media `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 2 {
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
}
seenSeries := false
seenMovie := false
for _, item := range res.Items {
switch item.TMDbID {
case 61709:
seenSeries = true
if item.EpisodeNum != 1 {
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
}
case 503314:
seenMovie = true
}
}
if !seenSeries || !seenMovie {
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
}
}
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+27 -6
View File
@@ -15,9 +15,12 @@ import (
// ─── History ────────────────────────────────────────────────────────────────
type progressReq struct {
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
SessionID string `json:"session_id"`
SessionStartedAtMs int64 `json:"session_started_at_ms"`
Sequence int64 `json:"sequence"`
}
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Playback.RecordProgress(
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
); err != nil {
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
UserID: toString(uid),
MediaID: req.MediaID,
PositionMs: req.PositionMs,
DurationMs: req.DurationMs,
SessionID: req.SessionID,
SessionStartedAtMs: req.SessionStartedAtMs,
Sequence: req.Sequence,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -76,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
if c.Query("ids") == "1" {
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if ids == nil {
ids = []string{}
}
c.JSON(http.StatusOK, gin.H{"ids": ids})
return
}
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2 -2
View File
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
if service.IsEmbyRemoteID(m.ID) {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
+38 -3
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
Auth: auth,
Media: service.NewMediaService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil),
Subtitle: service.NewSubtitleService(cfg, log, repos),
Permissions: permissions,
}
if err := repos.User.Create(t.Context(), &model.User{
@@ -413,7 +416,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
return router, svc, cfg.Secrets.JWTSecret
}
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
@@ -435,8 +438,40 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
if payload.StreamURL == "" {
t.Fatalf("expected non-empty stream_url")
}
if payload.HlsURL != "" {
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
resolveCalls := 0
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("probe resolver called")
})
request := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 0 {
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
}
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 1 {
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
}
}
+65
View File
@@ -0,0 +1,65 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestRecordProgressHandlerIgnoresStaleSessionSequence(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.POST("/history", recordProgressHandler(svc))
post := func(body string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code
}
if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent {
t.Fatalf("newer progress status = %d", code)
}
if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent {
t.Fatalf("stale progress status = %d", code)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || got.Sequence != 2 {
t.Fatalf("stale handler request overwrote newer state: %#v", got)
}
}
@@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
}
@@ -52,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(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("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
}
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
+12 -2
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
ctx := c.Request.Context()
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
// 远程剧集库:远程 Series 映射为系列卡片。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
@@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
@@ -122,6 +129,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
@@ -195,7 +203,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
grouped := service.GroupEpisodeVersionsForDisplay(items)
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
}
}
@@ -235,6 +244,7 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
grouped := service.GroupEpisodeVersionsForDisplay(items)
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
+2
View File
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
KeepExt *bool `json:"keep_ext"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
KeepExt: boolValue(req.KeepExt, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
+27
View File
@@ -3,6 +3,9 @@ package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
@@ -63,6 +66,7 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"POST /api/admin/strm/uploads/retry-failed",
"POST /api/admin/strm/uploads/cancel-pending",
"GET /api/strm/play/:provider/:file",
"HEAD /api/strm/play/:provider/:file",
} {
if !routes[want] {
t.Fatalf("%s route is not registered", want)
@@ -108,4 +112,27 @@ func TestStrmAccountsCRUD(t *testing.T) {
if w.Code == http.StatusInternalServerError {
t.Fatalf("strm play endpoint errored: %d", w.Code)
}
videoPath := filepath.Join(t.TempDir(), "sample.mkv")
if err := os.WriteFile(videoPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write test video: %v", err)
}
if err := repos.StrmSyncPath.Create(t.Context(), &model.StrmSyncPath{
Name: "local-test",
Provider: model.StrmProviderLocal,
RemotePath: filepath.Dir(videoPath),
Enabled: true,
}); err != nil {
t.Fatalf("create local sync path: %v", err)
}
// VidHub 等客户端会先用 HEAD 探测 STRM 播放源,不能返回 405/404。
req = httptest.NewRequest(http.MethodHead, "/api/strm/play/local/video.mkv?path="+url.QueryEscape(videoPath), nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("HEAD strm play endpoint = %d, want 200", w.Code)
}
if w.Body.Len() != 0 {
t.Fatalf("HEAD strm play endpoint returned body: %q", w.Body.String())
}
}
+23 -1
View File
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
var tracks []service.SubtitleTrack
var err error
if c.Query("include_embedded") == "true" {
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
} else {
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -28,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
}
}
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
+284
View File
@@ -0,0 +1,284 @@
package middleware
import (
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// EmbyCompatLogger 将 Emby/Jellyfin 兼容面的请求独立记录,便于针对具体
// 客户端、设备和未实现接口排查兼容问题。
//
// isEmbyPath 由 handler 层提供,用于识别 /emby/* 以及无前缀根路径形式的
// Emby API。除路径特征外,带 X-Emby-* / X-MediaBrowser-* 等客户端凭据的
// 请求也会被识别,因此尚未实现且没有已知路径前缀的新接口也能被记录。
func EmbyCompatLogger(log *zap.Logger, isEmbyPath func(string) bool) gin.HandlerFunc {
if log == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
if c == nil || c.Request == nil {
return
}
requestPath := c.Request.URL.Path
pathMatched := isEmbyPath != nil && isEmbyPath(requestPath)
hasSignature := hasEmbyClientSignature(c)
if !pathMatched && !hasSignature {
c.Next()
return
}
start := time.Now()
c.Next()
status := c.Writer.Status()
route := c.FullPath()
// 根路径 Emby 命名空间很宽,可能与 SPA 路由重名。没有客户端标识且
// 最终由 SPA 返回 200 时不应污染兼容日志;服务端路由或 404 仍记录。
if !hasSignature && !hasExplicitEmbyPrefix(requestPath) && strings.TrimSpace(route) == "" && status < http.StatusBadRequest {
return
}
unimplemented := isUnimplementedEmbyRequest(c, route, status, hasSignature)
fields := embyCompatLogFields(c, requestPath, status, route, time.Since(start), unimplemented)
switch {
case unimplemented:
log.Warn("emby API not implemented", fields...)
case status >= http.StatusBadRequest:
log.Warn("emby request failed", fields...)
default:
log.Info("emby request", fields...)
}
}
}
func isUnimplementedEmbyRequest(c *gin.Context, route string, status int, hasSignature bool) bool {
if strings.TrimSpace(route) != "" || c.Request.Method == http.MethodOptions {
return false
}
if status == http.StatusNotFound {
return true
}
// 无前端 API 前缀的未知 Emby 路径可能被 SPA 兜底为 200 HTML。
// 已识别为 Emby 客户端的这类响应同样说明兼容接口尚未实现。
return hasSignature && status >= http.StatusOK && status < http.StatusMultipleChoices &&
strings.Contains(strings.ToLower(c.Writer.Header().Get("Content-Type")), "text/html")
}
func embyCompatLogFields(c *gin.Context, requestPath string, status int, route string, duration time.Duration, unimplemented bool) []zap.Field {
client := embyCompatClientInfo(c)
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", requestPath),
zap.String("normalized_path", c.Request.URL.Path),
zap.String("route", route),
zap.Int("status", status),
zap.Duration("duration", duration),
zap.String("ip", c.ClientIP()),
zap.Bool("unimplemented", unimplemented),
}
if queryKeys := sortedQueryKeys(c); len(queryKeys) > 0 {
fields = append(fields, zap.Strings("query_keys", queryKeys))
}
if client.Client != "" {
fields = append(fields, zap.String("client", client.Client))
}
if client.Device != "" {
fields = append(fields, zap.String("device", client.Device))
}
if client.DeviceID != "" {
fields = append(fields, zap.String("device_id", client.DeviceID))
}
if client.Version != "" {
fields = append(fields, zap.String("client_version", client.Version))
}
if client.UserID != "" {
fields = append(fields, zap.String("user_id", client.UserID))
}
if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" {
fields = append(fields, zap.String("user_agent", userAgent))
}
if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" {
fields = append(fields, zap.String("content_type", contentType))
}
if errs := c.Errors.Errors(); len(errs) > 0 {
fields = append(fields, zap.Strings("errors", truncateStrings(errs, 8, 512)))
}
return fields
}
func hasEmbyClientSignature(c *gin.Context) bool {
for _, name := range []string{
"X-Emby-Token",
"X-MediaBrowser-Token",
"X-Emby-Authorization",
"X-MediaBrowser-Authorization",
"X-Emby-Client",
"X-MediaBrowser-Client",
"X-Emby-Device-Id",
"X-Emby-DeviceId",
"X-Emby-Device-Name",
"X-Emby-Version",
"X-Emby-UserId",
"X-MediaBrowser-Device-Id",
"X-MediaBrowser-DeviceId",
"X-MediaBrowser-Device-Name",
"X-MediaBrowser-Version",
"X-MediaBrowser-UserId",
} {
if strings.TrimSpace(c.GetHeader(name)) != "" {
return true
}
}
auth := strings.ToLower(strings.TrimSpace(c.GetHeader("Authorization")))
if strings.HasPrefix(auth, "emby ") || strings.HasPrefix(auth, "mediabrowser ") {
return true
}
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if strings.TrimSpace(c.Query(key)) != "" {
return true
}
}
ua := strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent")))
for _, marker := range []string{
"emby", "jellyfin", "infuse", "senplayer", "fileball", "vidhub", "hills", "rodelplayer",
} {
if strings.Contains(ua, marker) {
return true
}
}
return false
}
type embyCompatClient struct {
Client string
Device string
DeviceID string
Version string
UserID string
}
func embyCompatClientInfo(c *gin.Context) embyCompatClient {
auth := parseEmbyCompatAuthorization(firstNonEmptyString(
c.GetHeader("X-Emby-Authorization"),
c.GetHeader("X-MediaBrowser-Authorization"),
c.GetHeader("Authorization"),
))
return embyCompatClient{
Client: firstNonEmptyString(
c.GetHeader("X-Emby-Client"),
c.GetHeader("X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
auth["client"],
),
Device: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-MediaBrowser-Device-Name"),
c.Query("Device"),
c.Query("DeviceName"),
c.Query("device"),
c.Query("deviceName"),
auth["device"],
),
DeviceID: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-DeviceId"),
c.GetHeader("X-MediaBrowser-Device-Id"),
c.GetHeader("X-MediaBrowser-DeviceId"),
c.Query("DeviceId"),
c.Query("DeviceID"),
c.Query("deviceId"),
c.Query("deviceID"),
auth["deviceid"],
),
Version: firstNonEmptyString(
c.GetHeader("X-Emby-Version"),
c.GetHeader("X-MediaBrowser-Version"),
c.Query("Version"),
c.Query("version"),
auth["version"],
),
UserID: firstNonEmptyString(
c.Query("UserId"),
c.Query("userId"),
auth["userid"],
),
}
}
func parseEmbyCompatAuthorization(raw string) map[string]string {
out := map[string]string{}
raw = strings.TrimSpace(raw)
if raw == "" {
return out
}
if len(raw) >= len("MediaBrowser ") && strings.EqualFold(raw[:len("MediaBrowser ")], "MediaBrowser ") {
raw = raw[len("MediaBrowser "):]
} else if len(raw) >= len("Emby ") && strings.EqualFold(raw[:len("Emby ")], "Emby ") {
raw = raw[len("Emby "):]
}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.Trim(strings.TrimSpace(value), `"`)
if key != "" && value != "" {
out[key] = value
}
}
return out
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func sortedQueryKeys(c *gin.Context) []string {
query := c.Request.URL.Query()
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func truncateStrings(values []string, maxItems, maxLen int) []string {
if len(values) > maxItems {
values = values[:maxItems]
}
out := make([]string, 0, len(values))
for _, value := range values {
if len(value) > maxLen {
value = value[:maxLen] + "..."
}
out = append(out, value)
}
return out
}
func hasExplicitEmbyPrefix(path string) bool {
lower := strings.ToLower(strings.TrimSpace(path))
return lower == "/emby" || strings.HasPrefix(lower, "/emby/")
}
@@ -0,0 +1,124 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEmbyCompatLoggerRecordsClientAndUnimplementedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(path string) bool {
return strings.HasPrefix(path, "/emby/") || strings.HasPrefix(path, "/System/")
}))
router.GET("/emby/System/Info/Public", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ServerName": "MeBox"})
})
router.NoRoute(func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
successReq := httptest.NewRequest(http.MethodGet, "/emby/System/Info/Public?api_key=do-not-log", nil)
successReq.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Infuse", Device="Apple TV", DeviceId="device-1", Version="8.2", UserId="user-1", Token="secret-token"`)
successResp := httptest.NewRecorder()
router.ServeHTTP(successResp, successReq)
if successResp.Code != http.StatusOK {
t.Fatalf("success status = %d, want 200", successResp.Code)
}
missingReq := httptest.NewRequest(http.MethodPost, "/Search/Hints", nil)
missingReq.Header.Set("X-Emby-Token", "secret-token")
missingResp := httptest.NewRecorder()
router.ServeHTTP(missingResp, missingReq)
if missingResp.Code != http.StatusNotFound {
t.Fatalf("missing status = %d, want 404", missingResp.Code)
}
entries := logs.All()
if len(entries) != 2 {
t.Fatalf("logged entries = %d, want 2: %#v", len(entries), entries)
}
success := entries[0].ContextMap()
if success["path"] != "/emby/System/Info/Public" {
t.Fatalf("success path = %#v", success["path"])
}
if success["client"] != "Infuse" || success["device"] != "Apple TV" || success["device_id"] != "device-1" {
t.Fatalf("client fields not parsed: %#v", success)
}
if success["unimplemented"] != false {
t.Fatalf("success unimplemented = %#v, want false", success["unimplemented"])
}
if _, ok := success["token"]; ok {
t.Fatalf("token must not be logged: %#v", success)
}
if success["query_keys"] == nil {
t.Fatalf("query_keys missing: %#v", success)
}
if strings.Contains(entries[0].Message, "secret-token") {
t.Fatalf("token leaked in message: %q", entries[0].Message)
}
missing := entries[1].ContextMap()
if entries[1].Message != "emby API not implemented" {
t.Fatalf("missing message = %q", entries[1].Message)
}
if missing["path"] != "/Search/Hints" || missing["unimplemented"] != true {
t.Fatalf("missing fields = %#v", missing)
}
if missing["method"] != http.MethodPost {
t.Fatalf("missing method = %#v", missing["method"])
}
}
func TestEmbyCompatLoggerSkipsUnrelatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.GET("/api/health", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req.Header.Set("Authorization", "Bearer regular-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if logs.Len() != 0 {
t.Fatalf("unrelated request should not be logged: %#v", logs.All())
}
}
func TestEmbyCompatLoggerMarksSPAFallbackAsUnimplemented(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.NoRoute(func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("<html></html>"))
})
req := httptest.NewRequest(http.MethodGet, "/NewEmbyFeature/Test", nil)
req.Header.Set("X-Emby-Token", "secret-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("logged entries = %d, want 1", len(entries))
}
if entries[0].Message != "emby API not implemented" {
t.Fatalf("message = %q, want unimplemented warning", entries[0].Message)
}
if entries[0].ContextMap()["unimplemented"] != true {
t.Fatalf("unimplemented field missing: %#v", entries[0].ContextMap())
}
}
+6
View File
@@ -13,6 +13,12 @@ type PlaybackHistory struct {
DurationMs int64 `json:"duration_ms"`
WatchedAt time.Time `json:"watched_at"`
Completed bool `json:"completed"`
// 播放会话信息用于丢弃乱序到达的旧进度,避免旧请求把新的续播状态覆盖。
// 旧客户端不提供这些字段时保持 0,继续沿用无条件 upsert 语义。
SessionID string `gorm:"size:128;index" json:"-"`
SessionStartedAtMs int64 `gorm:"not null;default:0" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
// Favorite 将媒体项标记为给定用户的收藏。
+4 -1
View File
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
KeepExt bool `json:"keep_ext"`
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
@@ -128,7 +131,7 @@ type StrmUploadTask struct {
FileName string `gorm:"size:512" json:"file_name"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"`
+7
View File
@@ -28,6 +28,13 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
// 用户的开关影响其他人。
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+65 -16
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -13,24 +14,72 @@ import (
// upserts on (UserID, MediaID) so resume always reads the latest position.
type HistoryRepository struct{ db *gorm.DB }
// Upsert atomically inserts/updates the resume position in a single statement,
// relying on the uniq_user_history composite unique index. Concurrent progress
// reports for the same (user, media) can no longer double-insert.
// Upsert atomically inserts/updates the resume position in a single statement.
// Callers without playback-session metadata keep the legacy last-write-wins
// semantics (for example an explicit "mark played" action).
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
return r.upsert(ctx, h, false)
}
// UpsertProgress writes a progress report only when it is newer than the row
// currently stored. Reports from an older playback session, or an older
// sequence in the same session, are ignored. Reports without session metadata
// fall back to Upsert for compatibility with older clients.
func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.PlaybackHistory) error {
h.SessionID = strings.TrimSpace(h.SessionID)
if h.SessionID == "" || h.SessionStartedAtMs <= 0 {
return r.Upsert(ctx, h)
}
return r.upsert(ctx, h, true)
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
updates["session_id"] = h.SessionID
updates["session_started_at_ms"] = h.SessionStartedAtMs
updates["sequence"] = h.Sequence
}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(updates),
}
if versioned {
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Value: h.Sequence,
},
),
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}),
}).Create(h).Error
}}
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(h).Error
}
func playbackHistoryAssignments(h *model.PlaybackHistory) map[string]any {
return map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}
}
// ListByUser returns the most recent history rows for the user.
@@ -77,3 +77,105 @@ func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
}
}
func TestHistoryUpsertProgressRejectsStaleReports(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
watched := time.Now()
first := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: watched,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, first); err != nil {
t.Fatalf("first progress: %v", err)
}
newer := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 90_000,
DurationMs: 120_000,
WatchedAt: watched.Add(time.Minute),
Completed: true,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 2,
}
if err := repos.History.UpsertProgress(ctx, newer); err != nil {
t.Fatalf("newer progress: %v", err)
}
stale := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 10_000,
DurationMs: 120_000,
WatchedAt: watched.Add(2 * time.Minute),
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, stale); err != nil {
t.Fatalf("stale progress: %v", err)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed || got.Sequence != 2 {
t.Fatalf("stale report overwrote newer state: %#v", got)
}
oldSession := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 5_000,
DurationMs: 120_000,
WatchedAt: watched.Add(3 * time.Minute),
SessionID: "session-0",
SessionStartedAtMs: 500,
Sequence: 99,
}
if err := repos.History.UpsertProgress(ctx, oldSession); err != nil {
t.Fatalf("old session progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed {
t.Fatalf("old session overwrote newer state: %#v", got)
}
restart := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 120_000,
WatchedAt: watched.Add(4 * time.Minute),
SessionID: "session-2",
SessionStartedAtMs: 2_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, restart); err != nil {
t.Fatalf("restart progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 1_000 || got.Completed || got.SessionID != "session-2" {
t.Fatalf("new playback session did not reset state: %#v", got)
}
}
+20
View File
@@ -100,6 +100,26 @@ func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, li
return items, err
}
// ListAllByLibrariesFilteredNoCount loads every matching row without issuing a
// COUNT query. Full-library consumers such as series grouping must scan the
// whole library anyway, so calling it once is both cheaper and more consistent
// than issuing paginated queries with repeated counts.
func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) ([]model.Media, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, nil
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
q = applyMediaQueryFilter(q, filter)
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").Find(&items).Error
return items, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
+86 -26
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"path/filepath"
"strings"
"gorm.io/gorm"
@@ -10,6 +11,18 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// MediaUpsertItem carries a media row and optional sibling paths from an
// earlier keep_ext naming mode. An alias is only used when the exact new path
// does not exist in the database; in that case the existing row is migrated to
// the new path so scraped metadata survives renames such as:
//
// foo.strm -> foo.mkv.strm
// foo.mkv.strm -> foo.strm
type MediaUpsertItem struct {
Media *model.Media
AliasPaths []string
}
// Upsert inserts or updates a media row keyed by Path (unique index).
//
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
@@ -22,20 +35,14 @@ import (
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
var indexIDs []string
err := withSQLiteBusyRetry(ctx, func() error {
id, uerr := r.upsertWithDB(ctx, r.db, m)
if uerr != nil {
return uerr
}
indexIDs = append(indexIDs[:0], id)
return nil
})
if err != nil {
return err
}
r.indexByIDBestEffort(ctx, indexIDs)
return nil
return r.UpsertWithAliases(ctx, m, nil)
}
// UpsertWithAliases is Upsert with explicit, filesystem-verified STRM sibling
// aliases. It preserves the old row's ID, CreatedAt and scraped metadata while
// moving it to the current path.
func (r *MediaRepository) UpsertWithAliases(ctx context.Context, m *model.Media, aliasPaths []string) error {
return r.UpsertBatchWithAliases(ctx, []MediaUpsertItem{{Media: m, AliasPaths: aliasPaths}})
}
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
@@ -46,6 +53,17 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
mapped := make([]MediaUpsertItem, 0, len(items))
for _, item := range items {
if item != nil {
mapped = append(mapped, MediaUpsertItem{Media: item})
}
}
return r.UpsertBatchWithAliases(ctx, mapped)
}
// UpsertBatchWithAliases runs alias-aware upserts in one transaction.
func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []MediaUpsertItem) error {
if len(items) == 0 {
return nil
}
@@ -53,11 +71,11 @@ func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media)
err := withSQLiteBusyRetry(ctx, func() error {
indexIDs = indexIDs[:0]
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, m := range items {
if m == nil {
for _, item := range items {
if item.Media == nil {
continue
}
id, err := r.upsertWithDB(ctx, tx, m)
id, err := r.upsertWithDB(ctx, tx, item.Media, item.AliasPaths)
if err != nil {
return err
}
@@ -87,9 +105,9 @@ func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string)
}
}
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
// upsertWithDB 落库(新建、更新或从旧路径迁移),返回需要重建索引的媒体 ID。
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (string, error) {
existing, created, adopted, err := r.findOrCreateMediaByPath(ctx, db, m, aliasPaths)
if err != nil {
return "", err
}
@@ -98,6 +116,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
}
updates := mediaUpsertUpdates(existing, *m)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
updates["deleted_at"] = nil
}
}
if len(updates) == 0 {
*m = existing
return "", nil
@@ -107,31 +131,67 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return "", err
}
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
if adopted {
existing.Path = m.Path
existing.DeletedAt = gorm.DeletedAt{}
}
*m = existing
return existing.ID, nil
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if alias, aliasErr := r.findMediaByAlias(ctx, db, m.Path, aliasPaths); aliasErr == nil {
return alias, false, true, nil
} else if !errors.Is(aliasErr, gorm.ErrRecordNotFound) {
return model.Media{}, false, false, aliasErr
}
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, nil
return *m, true, false, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, createErr
return model.Media{}, false, false, createErr
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, nil
return existing, false, false, nil
}
}
if err != nil {
return model.Media{}, false, err
return model.Media{}, false, false, err
}
return existing, false, nil
return existing, false, false, nil
}
func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, currentPath string, aliasPaths []string) (model.Media, error) {
aliases := make([]string, 0, len(aliasPaths))
seen := make(map[string]struct{}, len(aliasPaths))
for _, alias := range aliasPaths {
alias = filepath.Clean(strings.TrimSpace(alias))
if alias == "" || alias == "." || alias == filepath.Clean(currentPath) {
continue
}
if _, ok := seen[alias]; ok {
continue
}
seen[alias] = struct{}{}
aliases = append(aliases, alias)
}
if len(aliases) == 0 {
return model.Media{}, gorm.ErrRecordNotFound
}
var existing model.Media
err := db.WithContext(ctx).Unscoped().
Where("path IN ?", aliases).
Order("CASE WHEN scrape_status = 'matched' THEN 0 ELSE 1 END ASC, " +
"CASE WHEN COALESCE(poster_url, '') <> '' OR COALESCE(overview, '') <> '' THEN 0 ELSE 1 END ASC, " +
"CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC").
First(&existing).Error
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
+29
View File
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"keep_ext": p.KeepExt,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
@@ -861,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
return out, nil
}
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Select("local_path", "size", "finished_at").
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
syncPathID, model.StrmTaskDone, since).
Order("finished_at DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, row := range rows {
if row.LocalPath == "" {
continue
}
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
if _, exists := out[row.LocalPath]; exists {
continue
}
out[row.LocalPath] = row.Size
}
return out, nil
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
+41 -26
View File
@@ -7,8 +7,17 @@ import (
"image/jpeg"
_ "image/png" // register PNG decoder
"strings"
"github.com/metatube-community/metatube-sdk-go/detector"
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
)
const adultPosterRatio = 2.0 / 3.0
var findPrimaryFaceAxisRatio = func(img image.Image, ratio float64, advanced bool) (float64, bool) {
return detector.FindPrimaryFaceAxisRatio(img, ratio, advanced)
}
// IsAdultMediaPathOrMetadata reports whether media is adult based on path, mediaType, nsfw flag, or adult code.
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
if nsfw {
@@ -38,7 +47,8 @@ func IsAdultArtworkURL(raw string) bool {
}
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
// If so, it crops the right portion (the front cover poster) and returns the encoded JPEG bytes.
// If so, it uses MetaTube's Pigo face detector to position a 2:3 poster crop.
// When no face is detected, it falls back to the conventional right-side front cover.
// If the image is already portrait or cannot be decoded, it safely returns the original data.
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
if len(data) == 0 {
@@ -59,32 +69,11 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return data, format, nil
}
// Front cover width is typically ~0.70-0.72 of the height (or right ~50-52% of total width)
cropWidth := int(float64(height) * 0.71)
maxCropWidth := int(float64(width) * 0.53)
if cropWidth > maxCropWidth {
cropWidth = maxCropWidth
}
if cropWidth <= 0 {
cropWidth = width / 2
}
minX := bounds.Max.X - cropWidth
if minX < bounds.Min.X {
minX = bounds.Min.X
}
cropRect := image.Rect(minX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
var cropped image.Image
if si, ok := src.(subImager); ok {
cropped = si.SubImage(cropRect)
} else {
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
cropped = dst
position := 1.0
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = detected
}
cropped := cropImageAtPosition(src, adultPosterRatio, position)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, cropped, &jpeg.Options{Quality: 92}); err != nil {
@@ -92,3 +81,29 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
}
return buf.Bytes(), "image/jpeg", nil
}
func cropImageAtPosition(src image.Image, ratio, position float64) image.Image {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
cropWidth, cropHeight := width, height
x, y := 0, 0
if candidate := int(float64(height) * ratio); candidate < width {
cropWidth = candidate
x = max(min(int(float64(width)*position)-cropWidth/2, width-cropWidth), 0)
} else if candidate := int(float64(width) / ratio); candidate < height {
cropHeight = candidate
y = max(min(int(float64(height)*position)-cropHeight/2, height-cropHeight), 0)
}
cropRect := image.Rect(0, 0, cropWidth, cropHeight).
Add(image.Pt(x, y)).
Add(bounds.Min)
if sub, ok := src.(interface {
SubImage(image.Rectangle) image.Image
}); ok {
return sub.SubImage(cropRect)
}
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
return dst
}
+49 -3
View File
@@ -2,6 +2,7 @@ package service
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/draw"
@@ -44,10 +45,10 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
if croppedWidth <= 0 || croppedWidth >= 800 {
t.Fatalf("unexpected cropped width: %d", croppedWidth)
}
// Verify aspect ratio is roughly standard portrait (~0.68 - 0.73)
// MetaTube's default primary-image ratio is 2:3.
ratio := float64(croppedWidth) / float64(croppedHeight)
if ratio < 0.65 || ratio > 0.75 {
t.Fatalf("expected portrait ratio ~0.71, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
if ratio < 0.65 || ratio > 0.68 {
t.Fatalf("expected portrait ratio ~0.667, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
}
// Verify the cropped image contains the right side color (blue), not the left side color (red)
@@ -58,6 +59,51 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
}
}
func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
wideWebP, err := base64.StdEncoding.DecodeString("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoJAAYAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=")
if err != nil {
t.Fatal(err)
}
croppedBytes, ctype, err := CropAdultCoverPoster(wideWebP)
if err != nil {
t.Fatalf("crop WebP: %v", err)
}
if ctype != "image/jpeg" {
t.Fatalf("cropped WebP type = %q, want image/jpeg", ctype)
}
cropped, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
t.Fatalf("cropped WebP ratio = %.3f, want 2:3", got)
}
}
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.25, true
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if r <= b {
t.Fatalf("face-positioned crop did not follow detected left-side axis: r=%d b=%d", r, b)
}
}
func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
// Create an already vertical portrait image (500x700)
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
+27
View File
@@ -0,0 +1,27 @@
package service
import (
"context"
"strings"
"github.com/truewhile/MeBox/internal/model"
)
// GetPeople resolves adult cast/crew on demand from MetaTube. The provider and
// remote movie ID are persisted in the existing external-ID columns; the
// returned people are intentionally not written to the database.
func (p *AdultProvider) GetPeople(ctx context.Context, media *model.Media) []map[string]any {
if p == nil || media == nil || !media.NSFW {
return nil
}
provider := strings.TrimSpace(media.TheTVDBID)
movieID := strings.TrimSpace(media.DoubanID)
if provider == "" || movieID == "" {
return nil
}
match, err := p.GetMetaTubeCandidate(ctx, provider, movieID)
if err != nil || match == nil {
return nil
}
return match.People
}
+81
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -180,6 +181,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
if err != nil {
return nil, err
}
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
for _, m := range matches {
m.OriginalName = code
m.Title = FormatAdultTitle(code, m.Title)
@@ -194,6 +196,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
if mtCfg.ServerURL != "" {
matches, err := p.metatube.Search(ctx, mtCfg, code)
if err == nil && len(matches) > 0 {
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
for _, m := range matches {
m.OriginalName = code
m.Title = FormatAdultTitle(code, m.Title)
@@ -217,6 +220,50 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
}
}
// GetMetaTubeCandidate fetches the selected provider result instead of
// re-running a search that may choose a different provider.
func (p *AdultProvider) GetMetaTubeCandidate(ctx context.Context, provider, id string) (*Match, error) {
if p == nil || p.metatube == nil {
return nil, nil
}
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
if provider == "" || id == "" {
return nil, nil
}
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
if engine != "metatube" && engine != "auto" {
return nil, nil
}
cfg := p.ResolveMetaTubeConfig(ctx)
if cfg.ServerURL == "" {
return nil, nil
}
return p.metatube.GetMovie(ctx, cfg, provider, id)
}
func (p *AdultProvider) enrichMetaTubeCandidates(ctx context.Context, cfg MetaTubeConfig, code string, matches []*Match) []*Match {
var wg sync.WaitGroup
for i, candidate := range matches {
if candidate == nil ||
normalizeAdultCode(candidate.OriginalName) != code ||
strings.TrimSpace(candidate.DoubanID) == "" ||
strings.TrimSpace(candidate.TheTVDBID) == "" {
continue
}
wg.Add(1)
go func(index int, current *Match) {
defer wg.Done()
detailed, err := p.metatube.GetMovie(ctx, cfg, current.TheTVDBID, current.DoubanID)
if err == nil && detailed != nil {
matches[index] = detailed
}
}(i, candidate)
}
wg.Wait()
return matches
}
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
bases := p.resolveBases(ctx)
if len(bases) == 0 {
@@ -243,12 +290,46 @@ func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match,
match.OriginalName = code
match.Title = FormatAdultTitle(code, match.Title)
match.NSFW = true
p.applyMetaTubeFaceAwareArtwork(ctx, code, match)
return match, nil
}
}
return nil, lastErr
}
func (p *AdultProvider) applyMetaTubeFaceAwareArtwork(ctx context.Context, code string, match *Match) {
if p == nil || p.metatube == nil || match == nil || strings.TrimSpace(match.PosterURL) == "" {
return
}
// In explicit built-in mode MetaTube remains useful as an image processor:
// its primary endpoint performs the same automatic face-aware crop used by
// the official Jellyfin plugin. Auto mode already tried MetaTube metadata,
// so avoid repeating the same failed request during fallback.
if strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin")) != "builtin" {
return
}
cfg := p.ResolveMetaTubeConfig(ctx)
if cfg.ServerURL == "" || !cfg.CropCover {
return
}
candidates, err := p.metatube.Search(ctx, cfg, code)
if err != nil {
return
}
for _, candidate := range candidates {
if candidate == nil || normalizeAdultCode(candidate.OriginalName) != code {
continue
}
if candidate.PosterURL != "" {
match.PosterURL = candidate.PosterURL
}
if candidate.BackdropURL != "" {
match.BackdropURL = candidate.BackdropURL
}
return
}
}
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
+1
View File
@@ -23,6 +23,7 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
}
match := &Match{
Provider: strings.ToLower(strings.TrimSpace(source)),
OriginalName: code,
MediaType: "adult",
NSFW: true,
+87 -1
View File
@@ -25,7 +25,8 @@ func TestAdultProviderRouting(t *testing.T) {
repos := repository.New(db)
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/movies/search" {
switch r.URL.Path {
case "/v1/movies/search":
q := r.URL.Query().Get("q")
if q == "SSIS-001" {
results := struct {
@@ -46,6 +47,21 @@ func TestAdultProviderRouting(t *testing.T) {
_ = json.NewEncoder(w).Encode(results)
return
}
case "/v1/movies/javdb/999":
_ = json.NewEncoder(w).Encode(struct {
Data MetaTubeMovieInfo `json:"data"`
}{
Data: MetaTubeMovieInfo{
ID: "999",
Number: "SSIS-001",
Title: "河北彩花 専属デビュー",
Provider: "javdb",
CoverURL: "https://example.com/poster.jpg",
PreviewImages: []string{"https://example.com/backdrop.jpg"},
ReleaseDate: "2021-06-19",
},
})
return
}
http.NotFound(w, r)
}))
@@ -80,6 +96,11 @@ func TestAdultProviderRouting(t *testing.T) {
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
}
// 2. Test auto mode with failing metatube query
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
@@ -89,3 +110,68 @@ func TestAdultProviderRouting(t *testing.T) {
t.Errorf("expected nil for nonexistent in auto mode when sources unavailable")
}
}
func TestBuiltinAdultScrapeUsesMetaTubeFaceAwareArtwork(t *testing.T) {
builtinServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
_, _ = w.Write([]byte(`<a class="box" href="/v/local"><strong>SSIS-001 本地候选</strong></a>`))
case "/v/local":
_, _ = w.Write([]byte(`<h2>SSIS-001 本地标题</h2><img class="video-cover" src="/wide-cover.jpg">`))
default:
http.NotFound(w, r)
}
}))
defer builtinServer.Close()
metaTubeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/movies/search" || r.URL.Query().Get("q") != "SSIS-001" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(struct {
Data []MetaTubeSearchResult `json:"data"`
}{
Data: []MetaTubeSearchResult{{
ID: "999",
Number: "SSIS-001",
Title: "MetaTube candidate",
Provider: "AVE",
CoverURL: "https://example.com/wide-cover.jpg",
}},
})
}))
defer metaTubeServer.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
for key, value := range map[string]string{
"adult.scraper.engine": "builtin",
"adult.scraper.builtin_javdb_url": builtinServer.URL,
"adult.scraper.metatube_server": metaTubeServer.URL,
"adult.scraper.crop_cover": "true",
} {
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
t.Fatal(err)
}
}
provider := NewAdultProvider(zap.NewNop(), nil, repos)
match, err := provider.Search(t.Context(), "SSIS-001")
if err != nil {
t.Fatal(err)
}
if match == nil {
t.Fatal("expected built-in match")
}
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("built-in poster = %q, want face-aware URL %q", match.PosterURL, wantPoster)
}
}
+115 -1
View File
@@ -3,10 +3,13 @@ package service
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"os"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -18,6 +21,12 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
type temporaryPasswordEntry struct {
userID string
username string
expiresAt time.Time
}
// AuthService handles registration, login, and JWT issuance.
type AuthService struct {
cfg *config.Config
@@ -25,11 +34,21 @@ type AuthService struct {
repo *repository.Container
tokenSvc *TokenService
permissionSvc *PermissionService
tempPassMu sync.RWMutex
tempPasswords map[string]temporaryPasswordEntry
}
// NewAuthService is the constructor.
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
return &AuthService{
cfg: cfg,
log: log,
repo: repo,
tokenSvc: tokenSvc,
permissionSvc: permissionSvc,
tempPasswords: make(map[string]temporaryPasswordEntry),
}
}
// Common service-level errors.
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
}
return string(h), nil
}
const temporaryPasswordTTL = 5 * time.Minute
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
if s == nil || s.repo == nil {
return "", 0, errors.New("auth service unavailable")
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return "", 0, ErrInvalidCredentials
}
if !user.IsActive {
return "", 0, ErrUserInactive
}
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
return "", 0, ErrUserExpired
}
n, err := rand.Int(rand.Reader, big.NewInt(900000))
if err != nil {
return "", 0, err
}
code := fmt.Sprintf("%06d", n.Int64()+100000)
s.tempPassMu.Lock()
defer s.tempPassMu.Unlock()
now := time.Now()
for k, v := range s.tempPasswords {
if now.After(v.expiresAt) {
delete(s.tempPasswords, k)
}
}
s.tempPasswords[code] = temporaryPasswordEntry{
userID: user.ID,
username: user.Username,
expiresAt: now.Add(temporaryPasswordTTL),
}
return code, int(temporaryPasswordTTL.Seconds()), nil
}
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
if s == nil || s.repo == nil {
return nil, false
}
code = strings.TrimSpace(code)
if len(code) != 6 {
return nil, false
}
s.tempPassMu.Lock()
entry, ok := s.tempPasswords[code]
if ok {
delete(s.tempPasswords, code)
}
s.tempPassMu.Unlock()
if !ok || time.Now().After(entry.expiresAt) {
return nil, false
}
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
return nil, false
}
user, err := s.repo.User.FindByID(ctx, entry.userID)
if err != nil || user == nil || !user.IsActive {
return nil, false
}
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
return nil, false
}
return user, true
}
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
if !ok || user == nil {
return nil, ErrInvalidCredentials
}
if s.tokenSvc == nil {
return nil, errors.New("token service unavailable")
}
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
if err != nil {
return nil, err
}
return &LoginResponse{
User: user,
Tokens: tokens,
}, nil
}
+13 -10
View File
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
if err != nil {
return nil, err
}
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
resp, err := c.HTTP.Do(req)
if err != nil {
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
// 避免长时间同步因 token 过期而整体失败。
if isTokenCode(base.Code) {
if access && c.tryRefreshTokenLocked(ctx) {
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
continue
}
if access {
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。
//
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
//
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
c.tokenMu.Lock()
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
oldAccess := c.AccessToken
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
// 不能再次轮换一次性的 refresh_token。
if failedAccess != "" && c.AccessToken != failedAccess {
c.tokenMu.Unlock()
return true
}
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
c.tokenMu.Unlock()
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
if ok && newToken != nil && c.OnTokenRefreshed != nil {
@@ -140,6 +140,47 @@ func TestRefreshTokenDead(t *testing.T) {
}
}
func TestConcurrentTokenFailuresShareOneRefresh(t *testing.T) {
var mu sync.Mutex
refreshCalls := 0
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/refreshToken" {
t.Errorf("unexpected path %s", r.URL.Path)
}
mu.Lock()
refreshCalls++
mu.Unlock()
time.Sleep(50 * time.Millisecond)
_, _ = w.Write([]byte(`{"state":true,"data":{"access_token":"at-new","refresh_token":"rt-new","expires_in":7200}}`))
})
client := NewOpenClient("100195129", "at-old", "rt-old")
var wg sync.WaitGroup
results := make(chan bool, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
results <- client.tryRefreshTokenLocked(context.Background(), "at-old")
}()
}
wg.Wait()
close(results)
for ok := range results {
if !ok {
t.Fatal("concurrent refresh should reuse the refreshed token")
}
}
mu.Lock()
defer mu.Unlock()
if refreshCalls != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
}
if client.CurrentAccessToken() != "at-new" {
t.Fatalf("access token = %q, want at-new", client.CurrentAccessToken())
}
}
func TestFsListAndDownload(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
+65 -3
View File
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
return files, resp.Count, nil
}
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return nil, nil, nil
}
const pageSize = 200
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
expectedSHA1 = strings.TrimSpace(expectedSHA1)
offset := 0
for page := 0; page < maxListPages; page++ {
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
if listErr != nil {
return nil, sameName, listErr
}
if len(files) == 0 {
break
}
for i := range files {
f := files[i]
if f.Category == TypeDir {
continue
}
// fta=0/2 表示未上传完成,不可作为已存在副本
if f.Fta == "0" || f.Fta == "2" {
continue
}
if f.FileName != fileName {
continue
}
sameName = append(sameName, f)
if matched != nil {
continue
}
if f.FileSize != expectedSize {
continue
}
remoteSha := strings.TrimSpace(f.Sha1)
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
cp := f
matched = &cp
}
}
if len(files) < pageSize {
break
}
offset += len(files)
}
return matched, sameName, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -423,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
c.tokenMu.Lock()
if refreshToken == "" {
refreshToken = c.currentRefreshToken()
refreshToken = c.RefreshTokenStr
}
if refreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("没有可用的 refresh_token")
}
token, err := c.doRefreshToken(refreshToken)
if err != nil {
// refresh_token 已失效时清空内存令牌(提示需重新授权)
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
c.setAuthTokenLocked("", "")
}
c.tokenMu.Unlock()
return nil, err
}
if token.AccessToken == "" || token.RefreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
c.tokenMu.Unlock()
if c.OnTokenRefreshed != nil {
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
}
return token, nil
}
@@ -0,0 +1,54 @@
package cloud115
import (
"context"
"fmt"
"net/http"
"testing"
)
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
]}`, sha, sha, sha)))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
if err != nil {
t.Fatal(err)
}
if matched == nil || matched.FileId != "keep" {
t.Fatalf("matched = %+v, want keep", matched)
}
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
}
}
func TestFindNamedContentInParentNoMatch(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":true,"data":[
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
]}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
if err != nil {
t.Fatal(err)
}
if matched != nil {
t.Fatalf("expected no match, got %+v", matched)
}
if len(sameName) != 1 {
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
}
}
@@ -0,0 +1,140 @@
package service
import (
"testing"
)
func TestDanmakuEpisodeNumber(t *testing.T) {
cases := []struct {
in string
want string
}{
{"第1话 裏切りの大空", "1"},
{"第18话 自己相似的两性同体-Fractal Androgynous-", "18"},
{"第11话", "11"},
{"【dandan&animeko】 第29集 那我们走吧", "29"},
{"第 202 集", "202"},
{"第1.5话 特别篇", "1.5"},
{"正片", ""},
{"", ""},
}
for _, tc := range cases {
if got := danmakuEpisodeNumber(tc.in); got != tc.want {
t.Errorf("danmakuEpisodeNumber(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDanmakuEpisodeSubtitle(t *testing.T) {
cases := []struct {
in string
want string
}{
{"第1话 裏切りの大空", "裏切りの大空"},
{"第18话 自己相似的两性同体-Fractal Androgynous-", "自己相似的两性同体-Fractal Androgynous-"},
{"第11话", ""},
{"【dandan&animeko】 第29集 那我们走吧", "那我们走吧"},
{"正片", ""},
}
for _, tc := range cases {
if got := danmakuEpisodeSubtitle(tc.in); got != tc.want {
t.Errorf("danmakuEpisodeSubtitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作
// 或缺少集标题的平台源抢走。
func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
{EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"},
}},
{AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
{EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"},
}},
{AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{
{EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"},
}},
}
got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章")
if len(got) != 1 {
t.Fatalf("matched %d anime, want 1: %#v", len(got), got)
}
if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 {
t.Fatalf("picked wrong source: %#v", got)
}
}
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
// 且顺序不可靠。必须靠副标题选中正确的那一集。
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
// 实测样本:官方 match「命运石之门 第18话 自己相似的两性同体-…」,
// 配置源首条却是《命运石之门 0》的同一集号。
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)", Episodes: []DanmakuEpisode{
{EpisodeID: 11072, EpisodeTitle: "【dandan&animeko】 第18话 并进对称的牵牛星-Translational"},
}},
{AnimeID: 2, AnimeTitle: "命运石之门(2011)", Episodes: []DanmakuEpisode{
{EpisodeID: 11095, EpisodeTitle: "【dandan&animeko】 第18话 自己相似的两性同体-Fractal Androgynous-"},
}},
}
got, ok := pickDanmakuEpisodeID(candidates, "18", "第18话 自己相似的两性同体-Fractal Androgynous-")
if !ok {
t.Fatal("expected a confident match")
}
if got != 11095 {
t.Fatalf("picked episodeId %d, want 11095 (first candidate is a different season)", got)
}
}
// 目标带副标题但没有任何候选的副标题对得上时,必须放弃而不是退回第一条,
// 否则会给用户播放另一部番的弹幕。
func TestPickDanmakuEpisodeIDRejectsWhenSubtitleNotFound(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番 第一季", Episodes: []DanmakuEpisode{
{EpisodeID: 111, EpisodeTitle: "第3话 完全不同的标题"},
}},
}
if got, ok := pickDanmakuEpisodeID(candidates, "3", "第3话 期望的标题"); ok {
t.Fatalf("expected no match, got episodeId %d", got)
}
}
// 目标没有副标题(如「第11话」)时,退而要求集数一致;集数对不上同样放弃。
func TestPickDanmakuEpisodeIDNumberOnlyFallback(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "86 第二季", Episodes: []DanmakuEpisode{
// 该源对第二季采用绝对集号,第 11 集记作第 22 话。
{EpisodeID: 11113, EpisodeTitle: "第22话 辛"},
}},
}
if got, ok := pickDanmakuEpisodeID(candidates, "11", "第11话"); ok {
t.Fatalf("episode number mismatch must be rejected, got episodeId %d", got)
}
same := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
{EpisodeID: 222, EpisodeTitle: "第11话"},
}},
}
got, ok := pickDanmakuEpisodeID(same, "11", "第11话")
if !ok || got != 222 {
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (222,true)", got, ok)
}
}
func TestPickDanmakuEpisodeIDSkipsInvalidIDs(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
{EpisodeID: 0, EpisodeTitle: "第1话 目标"},
{EpisodeID: -5, EpisodeTitle: "第1话 目标"},
{EpisodeID: 333, EpisodeTitle: "第1话 目标"},
}},
}
got, ok := pickDanmakuEpisodeID(candidates, "1", "第1话 目标")
if !ok || got != 333 {
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (333,true)", got, ok)
}
}
+426 -13
View File
@@ -105,27 +105,359 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
}
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
// 第 1 层拉弹幕:官方 match 给出的 episodeId 属于官方 ID 空间,不能直接拿去
// 请求第三方源(真实源上只会 404)。必须用官方给到的剧名+集数在配置源里重定位
// 到配置源自己的 episodeId,再用它拉弹幕。
func TestDanmakuFetchHashMatchRemapsEpisodeIDToConfiguredSource(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
nil)
// 配置源使用自己的 ID 空间:官方是 90001,配置源是 25484。
// 副标题一致,用于跨源确认是同一集。
const subtitle = "测试副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕A</d></i>`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mC", "", "")
require.NoError(t, err)
// 配置源优先:弹幕来自自定义源而非官方。
// 弹幕取自配置源,且用的是重定位后的 ID。
require.Contains(t, res.Raw, "弹幕A")
require.NotContains(t, res.Raw, "弹幕B官方")
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "hash", res.MatchMode)
}
// 配置源能定位到该集,但返回的是空弹幕库(count=0)时必须回官方兜底:
// 第三方目录里有条目不代表真的收录了弹幕。
func TestDanmakuFetchHashMatchFallsBackWhenConfiguredLibraryIsEmpty(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
const subtitle = "测试副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
// 该集在配置源上存在,但弹幕为空。
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":0,"comments":[]}`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方兜底弹幕</d></i>`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mEmpty", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mEmpty", "", "")
require.NoError(t, err)
require.Contains(t, res.Raw, "官方兜底弹幕")
require.Equal(t, int64(90001), res.EpisodeID)
}
// 同一集在配置源里命中多个来源时:自动加载第一条,其余作为可切换来源返回,
// 让用户能在面板里直接切换,而不必重新搜索。
func TestDanmakuFetchHashMatchReturnsAlternatives(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "多来源动画.第01话.mkv")
const subtitle = "同一个副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 三个来源都指向同一集(副标题与集数一致),模拟 LogVar 聚合多站。
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
`{"animeId":1,"animeTitle":"多来源动画 from dandan","episodes":[{"episodeId":101,"episodeTitle":"第1话 `+subtitle+`"}]},`+
`{"animeId":2,"animeTitle":"多来源动画 from bilibili","episodes":[{"episodeId":102,"episodeTitle":"第1话 `+subtitle+`"}]},`+
`{"animeId":3,"animeTitle":"多来源动画 from qq","episodes":[{"episodeId":103,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/101", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">首选来源弹幕</d></i>`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":9,"animeTitle":"多来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方弹幕</d></i>`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mAlt", "多来源动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mAlt", "", "")
require.NoError(t, err)
// 自动加载第一个来源(沿用既有取值逻辑)。
require.Contains(t, res.Raw, "首选来源弹幕")
require.Equal(t, int64(101), res.EpisodeID)
require.Equal(t, "hash", res.MatchMode)
// 三个来源全部作为可切换列表返回。
require.Len(t, res.Alternatives, 3)
var ids []int64
for _, a := range res.Alternatives {
for _, e := range a.Episodes {
ids = append(ids, e.EpisodeID)
}
}
require.Equal(t, []int64{101, 102, 103}, ids)
// Candidates 的语义必须保持不变(这里不是"必须选择"),否则前端会停止自动加载。
require.Empty(t, res.Candidates)
}
// 只有一个来源时不应产生 alternatives,避免面板出现无意义的单条列表。
func TestDanmakuFetchHashMatchNoAlternativesForSingleSource(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "单来源动画.第01话.mkv")
const subtitle = "唯一副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1,"animeTitle":"单来源动画","episodes":[{"episodeId":201,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/201", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">唯一来源弹幕</d></i>`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90002,"animeId":9,"animeTitle":"单来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mOne", "单来源动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mOne", "", "")
require.NoError(t, err)
require.Contains(t, res.Raw, "唯一来源弹幕")
require.Empty(t, res.Alternatives)
}
// 开启合并后:同一集的多个来源被合并,重复弹幕(时间+内容相同)只保留一条。
func TestDanmakuFetchMergeSourcesCombinesAndDeduplicates(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "合并动画.第01话.mkv")
const subtitle = "同一副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":301,"episodeTitle":"第1话 `+subtitle+`"}]},`+
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":302,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
// A 与 B 各有一条重复弹幕(1.0 秒「重复弹幕」)和各自独有的一条。
cfgMux.HandleFunc("/api/v2/comment/301", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":2,"comments":[`+
`{"p":"1.00,1,16777215,u1","m":"重复弹幕"},`+
`{"p":"2.00,1,16777215,u1","m":"只在A"}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/302", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":2,"comments":[`+
`{"p":"1.00,1,16777215,u2","m":"重复弹幕"},`+
`{"p":"3.00,1,16777215,u2","m":"只在B"}]}`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90003,"animeId":9,"animeTitle":"合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mMerge", "合并动画", videoPath, 32000, 0)
res, err := svc.FetchWithOptions(ctx, "mMerge", "", "", DanmakuFetchOptions{MergeSources: true})
require.NoError(t, err)
require.Equal(t, 2, res.MergedSources, "expected both sources to be merged")
merged := parseDanmakuComments(res.Raw)
require.Len(t, merged, 3, "duplicate comment must collapse: got %#v", merged)
require.Equal(t, "重复弹幕", merged[0].Text)
require.Equal(t, 1.0, merged[0].TimeSec)
require.Equal(t, "只在A", merged[1].Text)
require.Equal(t, "只在B", merged[2].Text)
// 合并结果用 JSON 输出,前端据此选择解析分支。
require.Equal(t, "json", res.SourceType)
}
// 未开启合并时,行为与原来一致:只加载自动选中的那一个来源。
func TestDanmakuFetchWithoutMergeLoadsSingleSource(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "不合并动画.第01话.mkv")
const subtitle = "同一副标题"
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":401,"episodeTitle":"第1话 `+subtitle+`"}]},`+
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":402,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/401", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u1","m":"只在A"}]}`)
})
cfgMux.HandleFunc("/api/v2/comment/402", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u2","m":"只在B"}]}`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90004,"animeId":9,"animeTitle":"不合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mNoMerge", "不合并动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mNoMerge", "", "")
require.NoError(t, err)
require.Zero(t, res.MergedSources)
require.Equal(t, int64(401), res.EpisodeID)
// 只应有自动选中来源的弹幕。
require.Contains(t, res.Raw, "只在A")
require.NotContains(t, res.Raw, "只在B")
// 两个来源仍然作为可切换项返回(合并开关不影响候选列表)。
require.Len(t, res.Alternatives, 2)
}
// 合并偏好按用户持久化。
func TestDanmakuMergeSourcesPreferencePersistsPerUser(t *testing.T) {
svc := newDanmakuTestService(t)
ctx := context.Background()
userA := model.User{Username: "merge-user-a", PasswordHash: "x", Role: "user", IsActive: true}
userA.ID = "user-a"
userB := model.User{Username: "merge-user-b", PasswordHash: "x", Role: "user", IsActive: true}
userB.ID = "user-b"
require.NoError(t, svc.repo.User.Create(ctx, &userA))
require.NoError(t, svc.repo.User.Create(ctx, &userB))
require.False(t, svc.MergeSourcesEnabled(ctx, "user-a"))
require.NoError(t, svc.SetMergeSources(ctx, "user-a", true))
require.True(t, svc.MergeSourcesEnabled(ctx, "user-a"))
// 另一个用户不受影响。
require.False(t, svc.MergeSourcesEnabled(ctx, "user-b"))
// 重新读取确认已落库,且 ConfigForUser 会带出该偏好。
cfg := svc.ConfigForUser(ctx, "user-a")
require.True(t, cfg.MergeSources)
require.False(t, svc.ConfigForUser(ctx, "user-b").MergeSources)
}
// 配置源搜不到对应剧集时必须回退官方:用官方自身的 episodeId 请求官方,
// 而不是拿官方 ID 去撞配置源。
func TestDanmakuFetchHashMatchFallsBackToOfficialWhenConfiguredHasNoMatch(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "冷门动画.第01话.mkv")
cfgMux := http.NewServeMux()
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
})
cfgSrv := httptest.NewServer(cfgMux)
t.Cleanup(cfgSrv.Close)
officialMux := http.NewServeMux()
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"冷门动画","episodeTitle":"第1话 无人知晓"}]}`)
})
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕来自官方</d></i>`)
})
official := httptest.NewServer(officialMux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mNoMatch", "冷门动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mNoMatch", "", "")
require.NoError(t, err)
require.Contains(t, res.Raw, "弹幕来自官方")
require.Equal(t, int64(90001), res.EpisodeID)
}
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
@@ -159,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
require.Contains(t, res.Raw, "弹幕官方兜底")
}
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配;
// 应继续走第 2 层按文件名+集数搜索。
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":false,"matches":[]}`,
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
`<i></i>`, nil)
overrideDanmakuOfficialBase(t, official.URL)
@@ -178,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕A")
// 第 2 层命中:搜索请求按文件名进行。
require.Contains(t, cfgSrv.lastSearch, "anime=")
require.Equal(t, "filename", res.MatchMode)
require.Equal(t, "测试动画", res.AnimeTitle)
// 第 2 层命中:搜索请求按文件名进行,而不是误用官方第一条模糊候选。
query, err := url.ParseQuery(cfgSrv.lastSearch)
require.NoError(t, err)
require.Contains(t, query.Get("anime"), "测试动画.第01话")
require.NotContains(t, query.Get("anime"), "91天")
}
// hash 未精确命中时,若本地已有刮削的剧名、年份、集数和集标题,应先用这些
// 元数据锁定正确来源,而不是直接进入大量候选的手工选择。
func TestDanmakuFetchHashMissUsesScrapedMetadata(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "local-release-name.mkv")
cfgSrv := newDanmakuSourceServerWithSearch(t,
`{"hasMore":false,"animes":[`+
`{"animeId":1,"animeTitle":"命运石之门 0(2018)【TV动画】from dandan&animeko","episodes":[{"episodeId":120140001,"episodeTitle":"【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}]},`+
`{"animeId":2,"animeTitle":"命运石之门(2011)【TV动画】from dandan&animeko","episodes":[{"episodeId":25484,"episodeTitle":"【dandan&animeko】 第1话 始与终的序章-Turning Point-"}]},`+
`{"animeId":3,"animeTitle":"命运石之门(2011)【动漫】from 360","episodes":[{"episodeId":120140002,"episodeTitle":"【qq】 第1集"}]}`+
`]}`)
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
`<i></i>`, nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
media := model.Media{
Title: "命运石之门",
Year: 2011,
EpisodeNum: 1,
EpisodeTitle: "起始与终结的序章",
Path: videoPath,
SizeBytes: 32000,
ScrapeStatus: "matched",
}
media.ID = "mScrapedMeta"
require.NoError(t, svc.repo.DB.Create(&media).Error)
res, err := svc.Fetch(ctx, media.ID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, "metadata", res.MatchMode)
require.Equal(t, "命运石之门(2011)【TV动画】from dandan&animeko", res.AnimeTitle)
require.Equal(t, int64(25484), res.EpisodeID)
require.Empty(t, res.Candidates)
}
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
@@ -446,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}
// 即使刮削元数据完整,也应先走准确率最高的 hash 层。
func TestDanmakuFetchPrefersHashOverCompleteMetadata(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"官方测试动画","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">hash命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
source := newDanmakuSourceServerWithSearch(t,
`{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL()))
m := model.Media{
Title: "测试动画",
Path: videoPath,
SizeBytes: 123,
EpisodeNum: 1,
EpisodeTitle: "起始与终结的序章",
Year: 2011,
}
m.ID = "hash-first"
require.NoError(t, svc.repo.DB.Create(&m).Error)
res, err := svc.Fetch(ctx, "hash-first", "", "")
require.NoError(t, err)
require.Equal(t, "hash", res.MatchMode)
require.NotEmpty(t, res.Raw)
require.Contains(t, seen, wantHash)
}
+220
View File
@@ -0,0 +1,220 @@
package service
import (
"encoding/json"
"encoding/xml"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// 弹幕合并:同一集在聚合源(如 LogVar)里常有多个库,开启合并后把这些库的
// 弹幕一起去重后展示,而不是只显示其中一个。
const (
// danmakuMergeMaxSources 限制一次合并涉及的来源数量,避免把一次播放
// 变成几十个上游请求。
danmakuMergeMaxSources = 10
// danmakuMergeConcurrency 限制并发抓取数,降低触发上游限流(429)的概率。
danmakuMergeConcurrency = 4
// danmakuMergeTimeToleranceSec 是判定「同一时间点」的容差。同一条弹幕在
// 不同源之间可能因精度处理差上零点几秒,用容差比对;文本仍要求完全一致,
// 因此不会把内容不同的弹幕误合。
danmakuMergeTimeToleranceSec = 0.5
)
// danmakuComment 是合并用的归一化弹幕。
type danmakuComment struct {
TimeSec float64
Mode int
Color int
Text string
}
// parseDanmakuComments 把上游载荷解析成归一化弹幕列表,兼容 dandanplay
// JSON({comments:[{p,m}]})与 Bilibili XML(<d p="...">text</d>)两种格式。
// 无法识别的载荷返回空列表,调用方据此跳过该来源。
func parseDanmakuComments(raw string) []danmakuComment {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
if comments := parseDanmakuCommentsJSON(trimmed); len(comments) > 0 {
return comments
}
}
if strings.HasPrefix(trimmed, "<") {
return parseDanmakuCommentsXML(trimmed)
}
return nil
}
func parseDanmakuCommentsJSON(raw string) []danmakuComment {
// 兼容 {comments:[...]} 与裸数组两种形态。
var payload struct {
Comments []struct {
P string `json:"p"`
M string `json:"m"`
// 少数自建源直接给结构化字段。
Time *float64 `json:"time"`
Text string `json:"text"`
Mode *int `json:"mode"`
Color *int `json:"color"`
} `json:"comments"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
var bare []struct {
P string `json:"p"`
M string `json:"m"`
Time *float64 `json:"time"`
Text string `json:"text"`
Mode *int `json:"mode"`
Color *int `json:"color"`
}
if err2 := json.Unmarshal([]byte(raw), &bare); err2 != nil {
return nil
}
payload.Comments = bare
}
out := make([]danmakuComment, 0, len(payload.Comments))
for _, item := range payload.Comments {
comment, ok := buildDanmakuComment(item.P, item.M, item.Time, item.Text, item.Mode, item.Color)
if ok {
out = append(out, comment)
}
}
return out
}
func parseDanmakuCommentsXML(raw string) []danmakuComment {
var doc struct {
Items []struct {
P string `xml:"p,attr"`
Text string `xml:",chardata"`
} `xml:"d"`
}
if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
return nil
}
out := make([]danmakuComment, 0, len(doc.Items))
for _, item := range doc.Items {
comment, ok := buildDanmakuComment(item.P, item.Text, nil, "", nil, nil)
if ok {
out = append(out, comment)
}
}
return out
}
// buildDanmakuComment 从 p 串或结构化字段构造一条弹幕。p 串格式为
// "time,mode,color,user"(dandanplay 四段式)。
func buildDanmakuComment(p, text string, timeSec *float64, plainText string, mode, color *int) (danmakuComment, bool) {
body := strings.TrimSpace(text)
if body == "" {
body = strings.TrimSpace(plainText)
}
if body == "" {
return danmakuComment{}, false
}
comment := danmakuComment{Text: body, Mode: 1}
if timeSec != nil {
comment.TimeSec = *timeSec
}
if mode != nil && *mode > 0 {
comment.Mode = *mode
}
if color != nil {
comment.Color = *color
}
if fields := strings.Split(p, ","); len(fields) >= 1 {
if t, err := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64); err == nil {
comment.TimeSec = t
}
if len(fields) >= 2 {
if m, err := strconv.Atoi(strings.TrimSpace(fields[1])); err == nil && m > 0 {
comment.Mode = m
}
}
// 颜色所在位置取决于格式,用段数区分(与前端 parseBilibiliXml 的判定
// 一致):Bilibili 的 p 是 "time,mode,fontSize,color,..."(>=5 段,
// 颜色在第 4 段);dandanplay 的 p 是 "time,mode,color,userId"(4 段,
// 颜色在第 3 段)。不区分会把字号当成颜色。
colorIndex := 2
if len(fields) >= 5 {
colorIndex = 3
}
if len(fields) > colorIndex {
if c, err := strconv.Atoi(strings.TrimSpace(fields[colorIndex])); err == nil {
comment.Color = c
}
}
}
if math.IsNaN(comment.TimeSec) || math.IsInf(comment.TimeSec, 0) || comment.TimeSec < 0 {
return danmakuComment{}, false
}
// 无颜色信息时用白色,与前端默认一致。
if comment.Color <= 0 {
comment.Color = 16777215
}
return comment, true
}
// mergeDanmakuComments 合并多组弹幕并按「时间 + 内容」去重。
//
// 判定重复的条件:文本完全一致,且时间差在 danmakuMergeTimeToleranceSec 以内。
// 之所以同时要求文本一致,是因为容差本身不足以区分内容;之所以需要容差,
// 是因为同一条弹幕在不同来源间可能因精度处理差上零点几秒。
func mergeDanmakuComments(sets [][]danmakuComment) []danmakuComment {
merged := make([]danmakuComment, 0, 512)
// keptTimes[文本] = 已保留的该文本时间列表,用于就近比对。
keptTimes := make(map[string][]float64)
for _, set := range sets {
for _, comment := range set {
times := keptTimes[comment.Text]
duplicate := false
for _, kept := range times {
if math.Abs(kept-comment.TimeSec) <= danmakuMergeTimeToleranceSec {
duplicate = true
break
}
}
if duplicate {
continue
}
keptTimes[comment.Text] = append(times, comment.TimeSec)
merged = append(merged, comment)
}
}
sort.SliceStable(merged, func(i, j int) bool { return merged[i].TimeSec < merged[j].TimeSec })
return merged
}
// encodeDanmakuComments 把合并结果编码成 dandanplay JSON,前端 parseDanmaku
// 已支持该格式({comments:[{p,m}]})。
func encodeDanmakuComments(comments []danmakuComment) string {
type item struct {
Cid int `json:"cid"`
P string `json:"p"`
M string `json:"m"`
T int `json:"t"`
}
payload := struct {
Count int `json:"count"`
Comments []item `json:"comments"`
}{Count: len(comments), Comments: make([]item, 0, len(comments))}
for i, comment := range comments {
payload.Comments = append(payload.Comments, item{
Cid: i + 1,
P: fmt.Sprintf("%.2f,%d,%d,merged", comment.TimeSec, comment.Mode, comment.Color),
M: comment.Text,
T: int(comment.TimeSec),
})
}
encoded, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(encoded)
}
+125
View File
@@ -0,0 +1,125 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseDanmakuCommentsSupportsDandanplayJson(t *testing.T) {
raw := `{"count":2,"comments":[` +
`{"cid":1,"p":"1.50,1,16777215,userA","m":"第一条"},` +
`{"cid":2,"p":"2.00,5,16711680,userB","m":"顶部红字"}]}`
comments := parseDanmakuComments(raw)
require.Len(t, comments, 2)
require.Equal(t, 1.5, comments[0].TimeSec)
require.Equal(t, 1, comments[0].Mode)
require.Equal(t, 16777215, comments[0].Color)
require.Equal(t, "第一条", comments[0].Text)
// 第 5 种模式是顶部弹幕,颜色 16711680 = 0xFF0000。
require.Equal(t, 5, comments[1].Mode)
require.Equal(t, 16711680, comments[1].Color)
}
func TestParseDanmakuCommentsSupportsBilibiliXml(t *testing.T) {
raw := `<?xml version="1.0" encoding="UTF-8"?><i>` +
`<d p="3.25,1,25,16777215,1700000000,0,abc,def">来自 XML</d>` +
`</i>`
comments := parseDanmakuComments(raw)
require.Len(t, comments, 1)
require.Equal(t, 3.25, comments[0].TimeSec)
require.Equal(t, "来自 XML", comments[0].Text)
// Bilibili 的 5 段式里第 3 段是字号、第 4 段才是颜色,必须取第 4 段。
require.Equal(t, 16777215, comments[0].Color)
}
func TestParseDanmakuCommentsReturnsNilForUnsupportedPayload(t *testing.T) {
require.Nil(t, parseDanmakuComments(""))
require.Nil(t, parseDanmakuComments("not a danmaku payload"))
}
// 同一时间 + 同一内容视为重复,只保留一条。
func TestMergeDanmakuCommentsDeduplicatesByTimeAndText(t *testing.T) {
setA := []danmakuComment{
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"},
{TimeSec: 5.0, Mode: 1, Color: 16777215, Text: "只有A有"},
}
setB := []danmakuComment{
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"}, // 与 A 完全重复
{TimeSec: 5.2, Mode: 1, Color: 16777215, Text: "只有B有"},
}
merged := mergeDanmakuComments([][]danmakuComment{setA, setB})
require.Len(t, merged, 3)
require.Equal(t, 1.0, merged[0].TimeSec)
require.Equal(t, "哈哈", merged[0].Text)
require.Equal(t, "只有A有", merged[1].Text)
require.Equal(t, "只有B有", merged[2].Text)
}
// 时间差在容差内且文本一致时也判为重复(跨源可能有零点几秒的精度差)。
func TestMergeDanmakuCommentsDeduplicatesWithinTolerance(t *testing.T) {
merged := mergeDanmakuComments([][]danmakuComment{
{{TimeSec: 10.0, Mode: 1, Text: "2333"}},
{{TimeSec: 10.3, Mode: 1, Text: "2333"}},
})
require.Len(t, merged, 1)
require.Equal(t, 10.0, merged[0].TimeSec)
}
// 内容相同但时间相距较远时是两条独立弹幕,不能合并。
func TestMergeDanmakuCommentsKeepsSameTextAtDifferentTimes(t *testing.T) {
merged := mergeDanmakuComments([][]danmakuComment{
{{TimeSec: 1.0, Mode: 1, Text: "前方高能"}},
{{TimeSec: 30.0, Mode: 1, Text: "前方高能"}},
})
require.Len(t, merged, 2)
}
// 时间相同但内容不同也不能合并。
func TestMergeDanmakuCommentsKeepsDifferentTextAtSameTime(t *testing.T) {
merged := mergeDanmakuComments([][]danmakuComment{
{{TimeSec: 2.0, Mode: 1, Text: "AAA"}},
{{TimeSec: 2.0, Mode: 1, Text: "BBB"}},
})
require.Len(t, merged, 2)
}
func TestMergeDanmakuCommentsSortsByTime(t *testing.T) {
merged := mergeDanmakuComments([][]danmakuComment{
{{TimeSec: 9.0, Mode: 1, Text: "后"}},
{{TimeSec: 1.0, Mode: 1, Text: "前"}},
})
require.Len(t, merged, 2)
require.Equal(t, 1.0, merged[0].TimeSec)
require.Equal(t, 9.0, merged[1].TimeSec)
}
// 编码结果必须能被前端的 dandanplay JSON 分支解析(p + m 两个字符串字段)。
func TestEncodeDanmakuCommentsProducesDandanplayShape(t *testing.T) {
encoded := encodeDanmakuComments([]danmakuComment{
{TimeSec: 1.5, Mode: 5, Color: 16711680, Text: "顶部"},
})
var payload struct {
Count int `json:"count"`
Comments []struct {
Cid int `json:"cid"`
P string `json:"p"`
M string `json:"m"`
T int `json:"t"`
} `json:"comments"`
}
require.NoError(t, json.Unmarshal([]byte(encoded), &payload))
require.Equal(t, 1, payload.Count)
require.Len(t, payload.Comments, 1)
require.Equal(t, "1.50,5,16711680,merged", payload.Comments[0].P)
require.Equal(t, "顶部", payload.Comments[0].M)
// 回环:编码后的载荷应能再次解析出一致的弹幕。
roundTrip := parseDanmakuComments(encoded)
require.Len(t, roundTrip, 1)
require.Equal(t, 1.5, roundTrip[0].TimeSec)
require.Equal(t, 5, roundTrip[0].Mode)
require.Equal(t, 16711680, roundTrip[0].Color)
require.Equal(t, "顶部", roundTrip[0].Text)
}
+731 -54
View File
@@ -13,10 +13,14 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"golang.org/x/sync/singleflight"
"go.uber.org/zap"
@@ -57,6 +61,14 @@ type DanmakuRenderConfig struct {
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
}
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
type DanmakuFetchOptions struct {
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
MergeSources bool
}
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
@@ -71,13 +83,21 @@ type DanmakuRenderConfig struct {
// metadata so the player UI can display which episode was loaded.
type DanmakuFetchResult struct {
DanmakuRenderConfig
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
// Alternatives 是「同一集的其它可选来源」。与 Candidates 语义不同:
// Candidates 表示自动匹配不唯一、必须由用户选择后才能加载弹幕;
// Alternatives 表示弹幕已经自动加载好了,这里额外提供同集的其它来源
// (LogVar 聚合了多个视频网站,同一集常有多个库)供用户随时切换,
// 不必再手动搜索一遍。
Alternatives []DanmakuAnime `json:"alternatives,omitempty"`
AnimeTitle string `json:"anime_title,omitempty"`
EpisodeTitle string `json:"episode_title,omitempty"`
EpisodeID int64 `json:"episode_id,omitempty"`
MatchMode string `json:"match_mode,omitempty"`
// MergedSources 表示本次结果由多少个来源合并而成(未合并时为 0)。
MergedSources int `json:"merged_sources,omitempty"`
}
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
@@ -118,6 +138,12 @@ type DanmakuService struct {
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
// resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走
// 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。
resultCacheMu sync.Mutex
resultCache map[string]danmakuResultCacheEntry
fetchGroup singleflight.Group
}
func danmakuHTTPClient() *http.Client {
@@ -129,10 +155,11 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
log = zap.NewNop()
}
return &DanmakuService{
log: log,
repo: repo,
client: danmakuHTTPClient(),
hashCache: make(map[string]string),
log: log,
repo: repo,
client: danmakuHTTPClient(),
hashCache: make(map[string]string),
resultCache: make(map[string]danmakuResultCacheEntry),
}
}
@@ -152,6 +179,121 @@ func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolv
}
}
// 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行
// 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化
// 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。
const (
danmakuResultCacheTTL = 6 * time.Hour
danmakuResultCacheMaxEntries = 64
// 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。
danmakuResultCacheCandidateTTL = 10 * time.Minute
// 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。
danmakuResultCacheEmptyTTL = time.Minute
)
type danmakuResultCacheEntry struct {
result *DanmakuFetchResult
expiresAt time.Time
storedAt time.Time
}
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
return strings.Join([]string{
strings.TrimSpace(source),
mediaID,
strings.TrimSpace(keyword),
strings.TrimSpace(episodeID),
strconv.FormatBool(merge),
}, "\x00")
}
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
if s == nil || key == "" {
return nil, false
}
now := time.Now()
s.resultCacheMu.Lock()
defer s.resultCacheMu.Unlock()
entry, ok := s.resultCache[key]
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
delete(s.resultCache, key)
return nil, false
}
return cloneDanmakuFetchResult(entry.result), true
}
func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) {
if s == nil || key == "" || result == nil {
return
}
now := time.Now()
s.resultCacheMu.Lock()
defer s.resultCacheMu.Unlock()
if s.resultCache == nil {
s.resultCache = make(map[string]danmakuResultCacheEntry)
}
ttl := danmakuResultCacheTTLFor(result)
if ttl <= 0 {
return
}
if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries {
oldestKey := ""
var oldest time.Time
for k, entry := range s.resultCache {
if oldestKey == "" || entry.storedAt.Before(oldest) {
oldestKey, oldest = k, entry.storedAt
}
}
delete(s.resultCache, oldestKey)
}
s.resultCache[key] = danmakuResultCacheEntry{
result: cloneDanmakuFetchResult(result),
expiresAt: now.Add(ttl),
storedAt: now,
}
}
// danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得
// 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。
func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration {
if result == nil {
return 0
}
if strings.TrimSpace(result.Raw) != "" {
return danmakuResultCacheTTL
}
if len(result.Candidates) > 0 {
return danmakuResultCacheCandidateTTL
}
return danmakuResultCacheEmptyTTL
}
// cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。
func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult {
if in == nil {
return nil
}
out := *in
out.Candidates = cloneDanmakuAnimeList(in.Candidates)
out.Alternatives = cloneDanmakuAnimeList(in.Alternatives)
return &out
}
func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
if in == nil {
return nil
}
out := make([]DanmakuAnime, len(in))
for i, anime := range in {
out[i] = anime
out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...)
}
return out
}
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
@@ -177,6 +319,41 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
return cfg
}
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
cfg := s.Config(ctx)
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
return cfg
}
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
if s == nil || s.repo == nil || s.repo.User == nil {
return false
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return false
}
return user.DanmakuMergeSources
}
// SetMergeSources 持久化该用户的弹幕合并偏好。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
if s == nil || s.repo == nil || s.repo.User == nil {
return errors.New("danmaku settings unavailable")
}
userID = strings.TrimSpace(userID)
if userID == "" {
return errors.New("missing user")
}
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
}
// Fetch retrieves danmaku for the given media. keyword overrides the
// media-derived search term (empty = use the video's own name); pass it from
// the player when the user searches for a custom title. episodeID forces a
@@ -186,19 +363,60 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
// 1. match: MD5 of the first 16MB of the video (local file read directly,
// .strm resolved to a direct link and range-fetched) → /api/v2/match
// against the official endpoint, which yields the episode library id.
// 2. search by the playing file's name + episode number.
// 3. current auto-identification (original name → title → file name + episode,
// 2. scraped metadata: title + episode number + episode title, filtered by
// release year and subtitle to disambiguate same-name seasons.
// 3. search by the playing file's name + episode number.
// 4. current auto-identification (original name → title → file name + episode,
// single hit used, several hits returned as candidates for the player).
// 4. manual: the player picks from the returned candidates (episodeID /
// 5. manual: the player picks from the returned candidates (episodeID /
// keyword override).
//
// Comments are always fetched from the configured source first (when set)
// and fall back to the official endpoint on failure; identification itself
// and fall back to the official endpoint on failure. Only hash identification
// always goes to the official endpoint.
//
// When danmaku is disabled the result carries Enabled=false so the player can
// silently skip rendering.
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{})
}
// FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 +
// 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。
func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
if s == nil {
return nil, errors.New("danmaku service unavailable")
}
cfg := s.Config(ctx)
if !cfg.Enabled {
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
}
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
// 等待期间可能已有同一 key 的请求写入缓存。
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
if err != nil {
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
return res, err
}
s.resultCachePut(key, res)
return res, nil
})
res, _ := value.(*DanmakuFetchResult)
if err != nil {
return cloneDanmakuFetchResult(res), err
}
return cloneDanmakuFetchResult(res), nil
}
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
if !res.Enabled {
return res, nil
@@ -234,6 +452,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
}
target := ""
// targetBase 是 target 所属的源。各源的 episodeId 空间互相独立,必须用
// 产生该 ID 的源去请求弹幕,否则会拿到 404;默认沿用「配置源优先」行为。
targetBase := configured
// hashOfficialID 记录 hash 层的官方 episodeId。仅当配置源重定位成功时
// 才填,用于「配置源该集无弹幕」时回官方兜底。
hashOfficialID := int64(0)
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
@@ -250,21 +474,59 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
match := matches[0]
res.AnimeTitle = match.AnimeTitle
res.EpisodeTitle = match.EpisodeTitle
res.EpisodeID = match.EpisodeID
res.MatchMode = "hash"
// match 返回的是官方 ID 空间的 episodeId,直接拿去问第三方源
// 只会 404(实测各源 ID 空间独立)。先用官方给到的剧名+集数
// 在配置源里重定位到它自己的 episodeId;定位不到就整条走官方。
if configured != "" && !sameDanmakuBase(configured, official) {
if matched, ok := s.lookupConfiguredEpisodes(ctx, configured, match); ok {
configuredID := firstDanmakuEpisodeID(matched)
target = strconv.FormatInt(configuredID, 10)
targetBase = configured
res.EpisodeID = configuredID
hashOfficialID = match.EpisodeID
// 同集有多个来源时全部带上,供面板里直接切换。
if len(matched) > 1 {
res.Alternatives = matched
}
}
}
if target == "" {
target = strconv.FormatInt(match.EpisodeID, 10)
targetBase = official
}
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
// 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。
// 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。
if target == "" && !manualKeyword && media != nil {
if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok {
configuredID := firstDanmakuEpisodeID(matched)
target = strconv.FormatInt(configuredID, 10)
targetBase = base
res.AnimeTitle = matched[0].AnimeTitle
res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle
res.EpisodeID = configuredID
res.MatchMode = "metadata"
if len(matched) > 1 {
res.Alternatives = matched
}
}
}
// 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
targetBase = base
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
@@ -273,10 +535,10 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
@@ -289,21 +551,159 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
targetBase = base
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
raw, st, err := s.fetchCommentWithFallback(ctx, targetBase, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
// 第三方目录里存在该集,不代表它真的收录了弹幕(实测部分条目返回
// count=0)。这种「拿到空库」的情况要用官方 episodeId 再试一次,否则
// 重定位后反而会静默变成无弹幕 —— 旧写法是靠官方 ID 撞 404 才走到官方
// 兜底的,改用重定位就必须显式补上这一步。
if hashOfficialID != 0 && danmakuCommentCount(raw) == 0 {
officialTarget := strconv.FormatInt(hashOfficialID, 10)
if officialRaw, officialType, officialErr := s.fetchCommentFromBase(ctx, official, officialTarget); officialErr == nil &&
danmakuCommentCount(officialRaw) > 0 {
raw, st = officialRaw, officialType
res.EpisodeID = hashOfficialID
} else if officialErr != nil {
s.log.Debug("danmaku official fallback for empty configured library failed",
zap.String("episode_id", officialTarget), zap.Error(officialErr))
}
}
// 合并多来源:仅在上游确实返回了多个同集来源时才有意义。
if opts.MergeSources && len(res.Alternatives) > 1 {
if mergedRaw, mergedCount, ok := s.mergeAlternativeSources(ctx, targetBase, res.Alternatives, res.EpisodeID, raw); ok {
raw, st = mergedRaw, "json"
res.MergedSources = mergedCount
}
}
res.Raw, res.SourceType = raw, st
return res, nil
}
// mergeAlternativeSources 并发抓取同一集的多个来源,按「时间 + 内容」去重后
// 合并成单个载荷。已有载荷(existingRaw)会被复用,避免重复请求。
// 返回合并后的载荷、实际参与合并的来源数与是否成功。
func (s *DanmakuService) mergeAlternativeSources(ctx context.Context, base string, alternatives []DanmakuAnime, existingID int64, existingRaw string) (string, int, bool) {
ids := make([]int64, 0, len(alternatives)+1)
seen := map[int64]bool{}
if existingID > 0 {
seen[existingID] = true
ids = append(ids, existingID)
}
for _, anime := range alternatives {
for _, ep := range anime.Episodes {
if ep.EpisodeID <= 0 || seen[ep.EpisodeID] {
continue
}
seen[ep.EpisodeID] = true
ids = append(ids, ep.EpisodeID)
if len(ids) >= danmakuMergeMaxSources {
break
}
}
if len(ids) >= danmakuMergeMaxSources {
break
}
}
// 少于两个来源时无需合并。
if len(ids) < 2 {
return "", 0, false
}
var (
mu sync.Mutex
collected [][]danmakuComment
wg sync.WaitGroup
sem = make(chan struct{}, danmakuMergeConcurrency)
)
collect := func(raw string) {
comments := parseDanmakuComments(raw)
if len(comments) == 0 {
return
}
mu.Lock()
collected = append(collected, comments)
mu.Unlock()
}
for _, id := range ids {
if id == existingID && existingRaw != "" {
collect(existingRaw)
continue
}
wg.Add(1)
go func(id int64) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
body, _, err := s.fetchCommentFromBase(ctx, base, strconv.FormatInt(id, 10))
if err != nil {
// 单个来源失败不影响整体合并,静默跳过。
return
}
collect(body)
}(id)
}
wg.Wait()
if len(collected) < 2 {
return "", 0, false
}
merged := mergeDanmakuComments(collected)
if len(merged) == 0 {
return "", 0, false
}
encoded := encodeDanmakuComments(merged)
if encoded == "" {
return "", 0, false
}
return encoded, len(collected), true
}
// fetchCommentFromBase 从单个源拉取弹幕,不做任何回退。
func (s *DanmakuService) fetchCommentFromBase(ctx context.Context, base, target string) (raw, sourceType string, err error) {
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
if err != nil {
return "", "auto", err
}
return raw, detectDanmakuSourceType(raw), nil
}
// danmakuCommentCount 估算弹幕条数,用于判断某个源是否真的返回了内容。
// 同时兼容 dandanplay JSON 与 Bilibili XML 两种载荷。
func danmakuCommentCount(raw string) int {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0
}
switch {
case strings.HasPrefix(trimmed, "{"):
var payload struct {
Count int `json:"count"`
Comments []json.RawMessage `json:"comments"`
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return 0
}
if payload.Count > 0 {
return payload.Count
}
return len(payload.Comments)
case strings.HasPrefix(trimmed, "<"):
return strings.Count(trimmed, "<d ")
default:
return 0
}
}
// detectDanmakuSourceType guesses the comment payload format from its body.
// The dandanplay protocol historically returned Bilibili-style XML, but newer
// and self-hosted implementations return the dandanplay JSON shape
@@ -369,7 +769,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
term.name = mediaSidecarBase(m.Path)
if term.name == "" {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
}
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
@@ -527,23 +930,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
if second := strings.ToLower(filepath.Ext(base)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return strings.TrimSpace(base)
return mediaSidecarBase(path)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
@@ -737,7 +1124,10 @@ type danmakuMatch struct {
// matchOfficial identifies the video via POST /api/v2/match on the official
// endpoint (always official, signed with the app credentials). Returns the
// candidate list; empty means nothing matched.
// candidate list only when the upstream explicitly reports a confident match;
// empty means nothing matched. The API may return fuzzy suggestions together
// with isMatched=false; those are not safe to auto-select because their first
// item can belong to an unrelated anime.
//
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
// names with errorCode 2 (verified against the live API — QueryEscape's
@@ -787,13 +1177,14 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
}
var out struct {
Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"`
Success bool `json:"success"`
IsMatched bool `json:"isMatched"`
Matches []danmakuMatch `json:"matches"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
}
if !out.Success {
if !out.Success || !out.IsMatched {
return nil, nil
}
return out.Matches, nil
@@ -807,13 +1198,14 @@ func sameDanmakuBase(a, b string) bool {
return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
}
// fetchCommentWithFallback fetches a comment library from the configured
// source first (when set and different from official), falling back to the
// official endpoint on failure.
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
// fetchCommentWithFallback fetches a comment library from primary first (when
// set and different from official), falling back to the official endpoint on
// failure. primary must be the source that issued target: episode ids are only
// meaningful inside the source that produced them.
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary, official, target string) (raw, sourceType string, err error) {
var bases []string
if configured != "" && !sameDanmakuBase(configured, official) {
bases = append(bases, configured)
if primary != "" && !sameDanmakuBase(primary, official) {
bases = append(bases, primary)
}
bases = append(bases, official)
var lastErr error
@@ -830,16 +1222,301 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configure
return "", "auto", lastErr
}
// searchCandidatesWithFallback searches the configured source first (when
// set and different from official), falling back to the official endpoint on
// failure.
func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
// searchCandidatesWithSource searches the configured source first (when set
// and different from official), falling back to the official endpoint on
// failure. It also reports which base produced the hits, because the caller
// must fetch comments from that same base — episode ids are source-local.
func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, string, error) {
if configured != "" && !sameDanmakuBase(configured, official) {
candidates, err := s.searchCandidates(ctx, configured, name, episode)
if err == nil {
return candidates, nil
return candidates, configured, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
}
return s.searchCandidates(ctx, official, name, episode)
candidates, err := s.searchCandidates(ctx, official, name, episode)
return candidates, official, err
}
// danmakuEpisodeNumberRE 从「第N话 / 第N話 / 第N集」里取出集数。各源标题格式
// 不一(有的只有集数、有的带副标题),正则只认集数标记本身。
var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`)
// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。
var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`)
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
func danmakuEpisodeNumber(title string) string {
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
if len(m) < 2 {
return ""
}
return m[1]
}
// danmakuEpisodeSubtitle 返回「第N话」之后的副标题,用于区分同名前作/续作。
func danmakuEpisodeSubtitle(title string) string {
loc := danmakuEpisodeNumberRE.FindStringIndex(title)
if loc == nil {
return ""
}
return strings.TrimSpace(title[loc[1]:])
}
// normalizeDanmakuText 去掉标点与空白,便于跨源比对副标题。注意它只做字符
// 归一化,不剥离集数标记 —— 调用方传入的可能已经是剥离后的副标题。
func normalizeDanmakuText(s string) []rune {
out := make([]rune, 0, 32)
for _, r := range strings.ToLower(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out = append(out, r)
}
}
return out
}
// danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。
func danmakuTitleYear(title string) int {
m := danmakuTitleYearRE.FindString(title)
if len(m) != 6 {
return 0
}
year, _ := strconv.Atoi(m[1:5])
return year
}
// danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。
//
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
// (「辛」「序曲」),但它们本身就是很强的标识。只有在做包含/前缀这类模糊
// 比对时才要求足够长度,避免短串误配到别的集。同一集在不同源的副标题长度
// 也可能不同(一侧带 "-Fractal Androgynous-" 之类后缀),故保留模糊分支。
func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
// candidateTitle 是完整标题,subtitle 已经剥离过集数标记,两者处理方式不同。
a := normalizeDanmakuText(danmakuEpisodeSubtitle(candidateTitle))
b := normalizeDanmakuText(subtitle)
if len(a) == 0 || len(b) == 0 {
return false
}
if string(a) == string(b) {
return true
}
const fuzzyMinRunes = 4
if len(a) < fuzzyMinRunes || len(b) < fuzzyMinRunes {
return false
}
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
return true
}
if danmakuRuneSimilarity(a, b) >= 0.7 {
return true
}
n := len(a)
if len(b) < n {
n = len(b)
}
if n > 12 {
n = 12
}
return string(a[:n]) == string(b[:n])
}
// danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。
// 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离;
// 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。
func danmakuRuneSimilarity(a, b []rune) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
prev := make([]int, len(b)+1)
for _, ra := range a {
cur := make([]int, len(b)+1)
for j, rb := range b {
if ra == rb {
cur[j+1] = prev[j] + 1
} else if cur[j] > prev[j+1] {
cur[j+1] = cur[j]
} else {
cur[j+1] = prev[j+1]
}
}
prev = cur
}
minLen := len(a)
if len(b) < minLen {
minLen = len(b)
}
return float64(prev[len(b)]) / float64(minLen)
}
// lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新
// 定位同一集,返回配置源里所有指向该集的结果。
//
// 必须重定位:各源 episodeId 空间互相独立,官方 ID(如 135500001)在第三方
// 源上只是一个不存在的编号,直接请求必然 404。官方 match 返回的 animeTitle /
// episodeTitle 正好提供了跨源检索所需的剧名、集数与副标题。
//
// 返回列表而非单条:LogVar 这类源聚合了多个视频网站,同一集常有多个库,
// 调用方取第一条自动加载,其余作为可切换来源交给用户。
func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configured string, m danmakuMatch) ([]DanmakuAnime, bool) {
episodeNum := danmakuEpisodeNumber(m.EpisodeTitle)
if episodeNum == "" || strings.TrimSpace(m.AnimeTitle) == "" {
return nil, false
}
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
if err != nil {
s.log.Debug("danmaku configured lookup failed",
zap.String("source", configured),
zap.String("anime", m.AnimeTitle),
zap.Error(err))
return nil, false
}
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
if len(matched) == 0 {
return nil, false
}
return matched, true
}
// lookupScrapedEpisodes uses already-scraped media metadata to locate the
// correct library when hash identification missed. Unlike the generic title
// search, this path requires an explicit episode-title match and, when known,
// a matching release year. That prevents Steins;Gate (2011) from being
// confused with Steins;Gate 0 (2018) or other same-name entries.
func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) {
title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media)
if !ok {
return nil, "", false
}
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum)
if err != nil {
s.log.Debug("danmaku scraped metadata lookup failed",
zap.String("title", title),
zap.String("episode", episodeNum),
zap.Error(err))
return nil, "", false
}
matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle)
if len(matched) == 0 {
return nil, "", false
}
return matched, base, true
}
// scrapedDanmakuMetadata returns the query fields only when the media has
// enough scraped information to identify a specific episode.
func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) {
if media == nil {
return "", "", "", false
}
title = strings.TrimSpace(media.Title)
if title == "" {
title = strings.TrimSpace(media.OriginalName)
}
episodeTitle = strings.TrimSpace(media.EpisodeTitle)
if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" {
return "", "", "", false
}
return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true
}
// matchScrapedDanmakuEpisodes filters title search hits by release year and
// episode subtitle. The returned list keeps the source grouping so multiple
// libraries for the same episode remain switchable in the player.
func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime {
out := make([]DanmakuAnime, 0, len(candidates))
for _, anime := range candidates {
if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) {
continue
}
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
if len(hits) == 0 {
continue
}
anime.Episodes = hits
out = append(out, anime)
}
return out
}
// danmakuAnimeTitleMatches checks the scraped title and, when present, the
// candidate's year. A candidate with a conflicting explicit year is rejected
// even if its title contains the scraped title.
func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool {
candidate := string(normalizeDanmakuText(animeTitle))
target := string(normalizeDanmakuText(mediaTitle))
if candidate == "" || target == "" || !strings.Contains(candidate, target) {
return false
}
if year > 0 {
if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year {
return false
}
}
return true
}
// firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
for _, anime := range matched {
for _, ep := range anime.Episodes {
if ep.EpisodeID > 0 {
return ep.EpisodeID
}
}
}
return 0
}
// pickDanmakuEpisodeID 从配置源的搜索结果里挑出与目标集最匹配的一条。
//
// 搜索按「剧名+集数」返回,但同名不同季/不同版本会同时命中,且顺序不保证
// 正确:实测「命运石之门 第18话」首条是《命运石之门 0(2018)》、《战区88 OVA
// 第1话》首条是《战区88(2004) TV》,两者内容都不对,只有副标题能区分。因此:
// - 目标带副标题时,必须找到副标题一致的候选,否则放弃(宁可回退官方,
// 也不能给用户放错番的弹幕);
// - 目标没有副标题(如「第11话」)时,退而要求集数一致。
func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle string) (int64, bool) {
if id := firstDanmakuEpisodeID(matchDanmakuEpisodes(candidates, episodeNum, episodeTitle)); id != 0 {
return id, true
}
return 0, false
}
// matchDanmakuEpisodes 在搜索结果里筛出所有指向目标集的结果,保留原有的番剧
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
// 列表作为可切换来源展示。
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
out := make([]DanmakuAnime, 0, len(candidates))
for _, anime := range candidates {
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
if len(hits) == 0 {
continue
}
anime.Episodes = hits
out = append(out, anime)
}
return out
}
// matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。
func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode {
subtitle := danmakuEpisodeSubtitle(episodeTitle)
// 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」),
// 不带「第1话」前缀。此时把它整体作为副标题参与比对。
if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" {
subtitle = strings.TrimSpace(episodeTitle)
}
hits := make([]DanmakuEpisode, 0, len(episodes))
for _, ep := range episodes {
if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum {
continue
}
// 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。
if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) {
continue
}
hits = append(hits, ep)
}
return hits
}
+80 -1
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/glebarez/sqlite"
@@ -22,7 +24,8 @@ func newDanmakuTestService(t *testing.T) *DanmakuService {
// 独立临时文件库,避免测试间通过共享内存库串数据。
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
// 需要 users 表:弹幕合并偏好按用户存储在 user 行上。
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}))
repos := repository.New(db)
t.Cleanup(func() {
sqlDB, err := db.DB()
@@ -325,3 +328,79 @@ func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
require.Equal(t, "xml", res2.SourceType)
require.Contains(t, res2.Raw, "弹幕A")
}
// 同一集在缓存 TTL 内重复请求时不应再访问上游(搜索和评论都只发一次)。
func TestDanmakuFetchCachesRepeatedRequests(t *testing.T) {
var searchCalls, commentCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&searchCalls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&commentCalls, 1)
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">缓存测试</d></i>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "cache-media", "测试动画", "", 1)
first, err := svc.Fetch(ctx, "cache-media", "", "")
require.NoError(t, err)
require.NotEmpty(t, first.Raw)
second, err := svc.Fetch(ctx, "cache-media", "", "")
require.NoError(t, err)
require.Equal(t, first.Raw, second.Raw)
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
require.EqualValues(t, 1, atomic.LoadInt32(&commentCalls))
}
// 并发的同一集请求应被 singleflight 合并,上游只被访问一次。
func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
var searchCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&searchCalls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">并发测试</d></i>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "concurrent-media", "测试动画", "", 1)
const workers = 8
start := make(chan struct{})
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := svc.Fetch(ctx, "concurrent-media", "", "")
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
}
+106 -8
View File
@@ -32,17 +32,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
}
return backdrop
}
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
if strings.HasPrefix(id, embyVirtualSeasonPrefix) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
// Latest JSON can outlive (or be served after) the in-memory artwork
// map. Rebuild from the library instead of handing the client a 1x1
// placeholder that it then caches as a successful image.
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
}
m, err := e.repo.Media.FindByID(ctx, id)
if err == nil && m != nil {
@@ -71,6 +68,107 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
return "", nil
}
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
season, ok, err := e.findSeasonGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
}
series, ok, err := e.findSeriesGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(series.PosterURL, series.BackdropURL), nil
}
// PersonImageURL resolves a person avatar from either a disguised remote ID
// or a person display name captured from a remote item's People field.
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) {
idOrName = strings.TrimSpace(idOrName)
if idOrName == "" || e == nil {
return "", nil
}
if IsEmbyRemoteID(idOrName) {
return e.ImageURL(ctx, idOrName, imageType)
}
if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil {
profilePath := tmdbProfilePathFromTag(tag)
if profilePath == "" {
profilePath = e.cachedPersonImage(idOrName)
}
if profilePath != "" {
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
e.rememberPersonImage(idOrName, profilePath)
return raw, nil
}
}
profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID)
if err != nil {
return "", err
}
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
e.rememberPersonImage(idOrName, profilePath)
return raw, nil
}
}
if e.remote != nil {
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
return raw, nil
}
}
return e.ImageURL(ctx, idOrName, imageType)
}
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
var imageInfoTypes = []string{"Primary", "Backdrop"}
// ImageInfos 返回条目的图片清单,对应 Emby 的 GET /Items/{Id}/Images。
// 客户端用它在详情页决定要加载哪些图;缺失该接口会落到 404,部分客户端
// 因此把条目当成"无图"而放弃渲染海报。
func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]any {
id = strings.TrimSpace(id)
if id == "" {
return nil
}
out := make([]map[string]any, 0, 2)
seen := map[string]bool{}
for _, imageType := range imageInfoTypes {
raw, err := e.ImageURL(ctx, id, imageType)
if err != nil {
continue
}
raw = strings.TrimSpace(raw)
// 非 Backdrop 类型在缺图时会回退到主图,去重避免同一张图重复出现。
if raw == "" || seen[raw] {
continue
}
seen[raw] = true
out = append(out, map[string]any{
"ImageType": imageType,
"ImageIndex": 0,
"ImageTag": id,
})
}
return out
}
// UserAvatarURL 返回用户头像的来源地址;用户未设置头像时返回空串。
func (e *EmbyService) UserAvatarURL(ctx context.Context, userID string) string {
userID = strings.TrimSpace(userID)
if userID == "" || e == nil || e.repo == nil || e.repo.User == nil {
return ""
}
user, err := e.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return ""
}
return strings.TrimSpace(user.AvatarURL)
}
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
if e == nil || strings.TrimSpace(id) == "" {
+54 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
@@ -61,6 +62,19 @@ type EmbyService struct {
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
peopleMu sync.RWMutex
peopleCache map[string]embyPeopleCacheEntry
// latestFlight collapses the homepage stampede: clients request Latest
// for every library at once, and a shared expiry used to rebuild each
// library in parallel.
latestFlight singleflight.Group
tmdb *TMDbProvider
adult *AdultProvider
personImageMu sync.RWMutex
personImages map[string]string
}
// NewEmbyService is the constructor.
@@ -76,6 +90,23 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
return e
}
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
if e != nil {
e.tmdb = tmdb
}
return e
}
// SetAdultProvider wires the on-demand adult-metadata provider used when a
// detail request needs cast/crew not stored in the database.
func (e *EmbyService) SetAdultProvider(adult *AdultProvider) *EmbyService {
if e != nil {
e.adult = adult
}
return e
}
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
if e != nil {
e.cache = cache
@@ -115,13 +146,27 @@ const (
embyVirtualSeriesPrefix = "msgo-series-"
embyVirtualSeasonPrefix = "msgo-season-"
embyVirtualCacheTTL = 10 * time.Minute
embyPeopleCacheTTL = 6 * time.Hour
embyPeopleEmptyCacheTTL = 15 * time.Minute
embyVisibilityCacheTTL = 30 * time.Second
embySeriesGroupingLimit = maxMediaSearchLimit
// Virtual artwork used to be wiped entirely once the in-memory map crossed
// a few thousand entries. A homepage refresh asks Latest for every library
// at once, so that wipe dropped the series the client was about to paint.
// Caps are sized for that fan-out; overflow evicts the oldest entries only.
embyVirtualSeriesCap = 8000
embyVirtualSeasonCap = 16000
embyVirtualArtworkCap = 24000
// Clients that already cached the 1x1 placeholder treat a stable tag as
// immutable. Virtual ids are the ones that served that placeholder, so
// only those tags get a suffix that forces a refetch.
embyVirtualPrimaryTagSuffix = "-p2"
embyVirtualBackdropTagSuffix = "-bd2"
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
@@ -131,6 +176,13 @@ type embyVisibilityCacheEntry struct {
expiresAt time.Time
}
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
// list refresh. TV clients commonly request the same posters/items repeatedly.
type embyPeopleCacheEntry struct {
people []map[string]any
expiresAt time.Time
}
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
+20
View File
@@ -3,12 +3,24 @@ package service
import (
"context"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"gorm.io/gorm"
)
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
var cached embyCountsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
return map[string]any{
"MovieCount": cached.MovieCount,
"SeriesCount": int(cached.SeriesCount),
"EpisodeCount": cached.EpisodeCount,
"ItemCount": cached.ItemCount,
}, nil
}
base := func() *gorm.DB {
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
return e.applyUserMediaVisibility(ctx, q, userID)
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
return nil, err
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
MovieCount: movieCount,
SeriesCount: int64(seriesCount),
EpisodeCount: episodeCount,
ItemCount: itemCount,
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return map[string]any{
"MovieCount": movieCount,
"SeriesCount": seriesCount,
+29 -5
View File
@@ -9,13 +9,22 @@ import (
)
type embyItemsCacheValue struct {
Items []map[string]any `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
StartIndex int `json:"start_index"`
Items []map[string]any `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
StartIndex int `json:"start_index"`
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
}
type embyLatestCacheValue struct {
Items []map[string]any `json:"items"`
Items []map[string]any `json:"items"`
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
}
type embyCountsCacheValue struct {
MovieCount int64 `json:"movie_count"`
SeriesCount int64 `json:"series_count"`
EpisodeCount int64 `json:"episode_count"`
ItemCount int64 `json:"item_count"`
}
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
@@ -43,10 +52,25 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
}
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
const defaultEmbyLatestCacheTTLSeconds = 300
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
// 观察到 73 个并发),缓存一旦集中过期,这批请求会同时穿透并各自重建
// payload。延长后稳态下几乎全部命中缓存,冷启动频率也随之下降。
func (e *EmbyService) embyLatestCacheTTLSeconds() int {
if e == nil || e.cfg == nil || e.cfg.Cache.EmbyLatestTTLSeconds < 1 {
return defaultEmbyLatestCacheTTLSeconds
}
return e.cfg.Cache.EmbyLatestTTLSeconds
}
func (e *EmbyService) mediaCacheTTLSeconds() int {
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
return 90
+497 -58
View File
@@ -2,10 +2,17 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
@@ -58,14 +65,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seasonPayload(season), nil
item := e.seasonPayload(season)
if media := seriesPeopleMedia(season.Series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
@@ -76,7 +91,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
return nil, nil
}
@@ -99,46 +118,90 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
}
}
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if e == nil {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
return items, nil
}
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
loadCtx := context.WithoutCancel(ctx)
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
return embyLatestCacheValue{Items: items}, nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
if e.cache != nil {
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, nil
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return nil, err
}
cached, _ := v.(embyLatestCacheValue)
if cached.Items == nil {
return []map[string]any{}, nil
}
return cached.Items, nil
}
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
if e == nil || e.cache == nil {
return nil, false
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
return nil, false
}
e.rememberArtworkRefs(cached.Artwork)
if cached.Items == nil {
return []map[string]any{}, true
}
return cached.Items, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
if out == nil {
out = []map[string]any{}
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err == nil && e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
return out, err
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
}
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
}
@@ -151,7 +214,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
rows = e.collapseMediaVersionRows(ctx, rows)
if len(rows) > limit {
@@ -159,15 +222,12 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
out, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
return embyLatestCacheValue{Items: out}, nil
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
@@ -175,8 +235,9 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
@@ -187,7 +248,25 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
for _, group := range groups {
items = append(items, e.seriesPayload(group))
}
return items, nil
return items, e.artworkRefsForSeriesGroups(groups), nil
}
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
// 仍走完整分组。上限 2000 行。
func embyLatestSeriesRowLimit(limit int) int {
if limit < 1 {
limit = 20
}
n := limit * 40
if n < 200 {
n = 200
}
if n > 2000 {
n = 2000
}
return n
}
// ResumeItems 列出有未完成播放进度的媒体。
@@ -251,7 +330,7 @@ func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[str
continue
}
}
items = append(items, e.itemPayload(ctx, m, true, 0))
items = append(items, e.itemPayload(ctx, m, true, 0, false))
continue
}
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
@@ -374,7 +453,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
items := make([]map[string]any, 0, p.Limit)
slots := make([]resumeSlot, 0, len(hist))
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
@@ -382,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
}
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
remoteTotal++
if len(items) >= needed {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
}
for {
filled := 0
pending := make([]*resumeRemoteFetch, 0)
for i := range slots {
if slots[i].item != nil {
filled++
if filled >= needed {
break
}
continue
}
f := slots[i].fetch
if f == nil || f.done {
continue
}
if f.mount == nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
f.done = true
continue
}
f.mount, f.acct, f.remoteID = mount, acct, remoteID
}
pending = append(pending, f)
if filled+len(pending) >= needed {
break
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
items = append(items, item)
if len(pending) == 0 {
break
}
e.fillResumeRemotes(ctx, pending)
for i := range slots {
f := slots[i].fetch
if f == nil || !pendingContains(pending, f) {
continue
}
f.done = true
item := f.item
f.item = nil
if item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
slots[i].item = item
}
}
items := make([]map[string]any, 0, p.Limit)
for i := range slots {
if slots[i].item == nil {
continue
}
items = append(items, slots[i].item)
}
total := int64(localTotal + remoteTotal)
@@ -422,7 +542,58 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
type resumeSlot struct {
item map[string]any
fetch *resumeRemoteFetch
}
type resumeRemoteFetch struct {
hist model.PlaybackHistory
mount *model.EmbyMount
acct *model.StrmAccount
remoteID string
item map[string]any
done bool
}
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
for _, row := range rows {
if row == target {
return true
}
}
return false
}
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
if e == nil || e.remote == nil || len(rows) == 0 {
return
}
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
if err != nil || item == nil {
return
}
rows[i].item = item
}(i)
}
wg.Wait()
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
itemType := "Movie"
name := m.Title
parentID := m.LibraryID
@@ -455,6 +626,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
if durationMs <= 0 && posMs > 0 {
durationMs = posMs * 2
if durationMs < 30*60*1000 {
durationMs = 30 * 60 * 1000
}
runTimeTicks = durationMs * 10_000
}
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
@@ -488,6 +666,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"People": []map[string]any{},
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
@@ -501,8 +680,268 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
if primaryArtwork != "" {
item["PrimaryImageTag"] = m.ID
}
if seriesID != "" {
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
if sEntry.posterURL != "" {
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
}
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
item["ParentBackdropItemId"] = seriesID
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(seriesID, embyVirtualBackdropTagSuffix)}
}
}
}
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
if includePeople {
item["People"] = e.resolveMediaPeople(ctx, m)
}
return item
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil {
return []map[string]any{}
}
cacheKey := embyMediaPeopleCacheKey(m)
if cacheKey == "" {
return []map[string]any{}
}
if people, ok := e.cachedMediaPeople(cacheKey); ok {
return people
}
people := e.fetchTMDbPeople(ctx, m)
if len(people) == 0 {
people = e.resolveNFOMediaPeople(m)
}
e.rememberMediaPeople(cacheKey, people)
return people
}
func seriesPeopleMedia(group embySeriesGroup) *model.Media {
if group.TMDbID <= 0 {
return nil
}
if len(group.Episodes) > 0 {
media := group.Episodes[0]
media.TMDbID = group.TMDbID
return &media
}
return &model.Media{Title: group.Name, TMDbID: group.TMDbID, SeasonNum: 1}
}
func (e *EmbyService) fetchTMDbPeople(ctx context.Context, m *model.Media) []map[string]any {
if e == nil || m == nil {
return nil
}
var people []map[string]any
if e.tmdb != nil && m.TMDbID > 0 {
mediaType := "movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
mediaType = "tv"
}
people, err := e.tmdb.GetPeople(ctx, m.TMDbID, mediaType)
if err != nil {
if e.log != nil {
e.log.Debug("tmdb: detail people lookup failed", zap.Int("tmdb_id", m.TMDbID), zap.String("type", mediaType), zap.Error(err))
}
} else if len(people) > 0 {
e.rememberPersonImages(people)
return people
}
}
if m.NSFW && e.adult != nil {
people = e.adult.GetPeople(ctx, m)
}
return people
}
func embyMediaPeopleCacheKey(m *model.Media) string {
if m == nil {
return ""
}
if m.TMDbID > 0 {
mediaType := "movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
mediaType = "tv"
}
return fmt.Sprintf("tmdb:%d:%s", m.TMDbID, mediaType)
}
if strings.TrimSpace(m.ID) != "" {
return "media:" + strings.TrimSpace(m.ID)
}
if strings.TrimSpace(m.Path) != "" {
return "path:" + strings.ToLower(filepath.Clean(m.Path))
}
return ""
}
func (e *EmbyService) resolveNFOMediaPeople(m *model.Media) []map[string]any {
if m == nil || strings.TrimSpace(m.Path) == "" {
return []map[string]any{}
}
dir := filepath.Dir(m.Path)
candidates := make([]string, 0, 6)
seenPath := map[string]struct{}{}
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
key := strings.ToLower(filepath.Clean(path))
if _, ok := seenPath[key]; ok {
return
}
seenPath[key] = struct{}{}
candidates = append(candidates, path)
}
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
add(nfoPath(m.Path))
for _, base := range mediaSidecarBaseVariants(m.Path) {
add(filepath.Join(dir, base+".nfo"))
}
add(filepath.Join(dir, "movie.nfo"))
add(filepath.Join(dir, "tvshow.nfo"))
people := make([]map[string]any, 0)
seen := make(map[string]bool)
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, _, err := decodeNFOFile(p)
if err == nil && doc != nil {
for _, d := range doc.Directors {
name := strings.TrimSpace(d)
if name == "" {
continue
}
personID := embyPersonID(name, "Director")
if seen[personID] {
continue
}
seen[personID] = true
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Director",
"Role": "Director",
})
}
for _, a := range doc.Actors {
name := strings.TrimSpace(a.Name)
if name == "" {
continue
}
personID := embyPersonID(name, "Actor")
if seen[personID] {
continue
}
seen[personID] = true
role := strings.TrimSpace(a.Role)
if role == "" {
role = "Actor"
}
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Actor",
"Role": role,
})
}
break
}
}
}
return people
}
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
if e == nil || strings.TrimSpace(key) == "" {
return nil, false
}
now := time.Now()
e.peopleMu.RLock()
entry, ok := e.peopleCache[key]
e.peopleMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.peopleMu.Lock()
delete(e.peopleCache, key)
e.peopleMu.Unlock()
}
return nil, false
}
out := make([]map[string]any, len(entry.people))
copy(out, entry.people)
return out, true
}
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
if e == nil || strings.TrimSpace(key) == "" {
return
}
e.peopleMu.Lock()
defer e.peopleMu.Unlock()
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
}
stored := make([]map[string]any, len(people))
copy(stored, people)
ttl := embyPeopleCacheTTL
if len(stored) == 0 {
ttl = embyPeopleEmptyCacheTTL
}
e.peopleCache[key] = embyPeopleCacheEntry{
people: stored,
expiresAt: time.Now().Add(ttl),
}
}
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
if e == nil || len(people) == 0 {
return
}
e.personImageMu.Lock()
defer e.personImageMu.Unlock()
if e.personImages == nil || len(e.personImages) > 20000 {
e.personImages = make(map[string]string, 128)
}
for _, person := range people {
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
e.personImages[id] = profilePath
}
}
}
func (e *EmbyService) cachedPersonImage(id string) string {
if e == nil {
return ""
}
e.personImageMu.RLock()
profilePath := e.personImages[strings.TrimSpace(id)]
e.personImageMu.RUnlock()
return profilePath
}
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
profilePath = strings.TrimSpace(profilePath)
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
return
}
e.personImageMu.Lock()
if e.personImages == nil {
e.personImages = make(map[string]string, 128)
}
e.personImages[strings.TrimSpace(id)] = profilePath
e.personImageMu.Unlock()
}
func embyPersonID(name, roleType string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
return "person-" + hex.EncodeToString(sum[:8])
}
+28 -7
View File
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
orderIncludesDirection = false
case "premieredate", "productionyear":
order = mediaReleaseOrderSQL(desc)
case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "dateplayed":
order = "resume.watched_at"
orderIncludesDirection = false
@@ -181,7 +181,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
items := make([]map[string]any, 0, len(rows))
for _, m := range rows {
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false))
}
return items, nil
}
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
}
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return map[string]any{
"Items": cached.Items,
"TotalRecordCount": int(cached.TotalRecordCount),
"StartIndex": cached.StartIndex,
}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if libraryID != "" {
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(pageGroups))
for _, group := range pageGroups {
items = append(items, e.seriesPayload(group))
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
Items: items,
TotalRecordCount: int64(total),
StartIndex: p.StartIndex,
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
+31 -3
View File
@@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
return []model.Media{*m}
}
targetKey := e.mediaVersionKey(ctx, m)
filtered := rows[:0]
for i := range rows {
if e.mediaVersionKey(ctx, &rows[i]) == targetKey {
filtered = append(filtered, rows[i])
}
}
rows = filtered
if len(rows) == 0 {
return []model.Media{*m}
}
rows = e.collapseExactPathRows(rows)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ID == m.ID {
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if libraryGroup == "" {
libraryGroup = strings.TrimSpace(m.LibraryID)
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
}
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" {
return stemKey + kindKey
}
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
+66 -16
View File
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return map[string]any{
"Items": cached.Items,
"TotalRecordCount": int(cached.TotalRecordCount),
"StartIndex": cached.StartIndex,
}, nil
}
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
apply := func(q *gorm.DB) *gorm.DB {
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
}
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
if err != nil {
return nil, err
}
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
// 查询并把响应时间浪费在用户根本看不到的条目上。
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
type entry struct {
sortAt time.Time
payload map[string]any
sortAt time.Time
media *model.Media
group *embySeriesGroup
}
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
for _, g := range seriesGroups {
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
for i := range seriesGroups {
group := &seriesGroups[i]
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
}
for _, item := range movieItems {
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
for i := range movieRows {
media := &movieRows[i]
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].sortAt.After(entries[j].sortAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(paged))
pageMovies := make([]model.Media, 0, len(paged))
for _, en := range paged {
items = append(items, en.payload)
if en.media != nil {
pageMovies = append(pageMovies, *en.media)
}
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
if err != nil {
return nil, err
}
payloadByID := make(map[string]map[string]any, len(moviePayloads))
for _, item := range moviePayloads {
if id, ok := item["Id"].(string); ok {
payloadByID[id] = item
}
}
items := make([]map[string]any, 0, len(paged))
pageGroups := make([]embySeriesGroup, 0, len(paged))
for _, en := range paged {
switch {
case en.group != nil:
pageGroups = append(pageGroups, *en.group)
items = append(items, e.seriesPayload(*en.group))
case en.media != nil:
if item := payloadByID[en.media.ID]; item != nil {
items = append(items, item)
}
}
}
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
Items: items,
TotalRecordCount: int64(total),
StartIndex: p.StartIndex,
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
@@ -220,7 +270,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%",
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
@@ -243,7 +293,7 @@ func embyMediaPathLooksEpisodic(path string) bool {
return false
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
+87 -30
View File
@@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct {
found bool // 库不存在时 found=false,调用方可退回计数启发式
}
type embyPayloadSeriesEntry struct {
title string
posterURL string
backdropURL string
found bool
}
type embyPayloadCache struct {
mu sync.Mutex
libTypes map[string]embyLibraryTypeEntry
series map[string]string // series_id -> title("" 表示不存在/无标题)
series map[string]embyPayloadSeriesEntry
}
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
@@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry)
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
title, ok := c.series[id]
return title, ok
entry, ok := c.series[id]
if !ok {
return "", false
}
return entry.title, true
}
func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.series[id]
return entry, ok
}
func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = entry
}
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = title
entry := c.series[id]
entry.title = title
entry.found = true
c.series[id] = entry
}
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
@@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
}
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
libTypes: map[string]embyLibraryTypeEntry{},
series: map[string]string{},
series: map[string]embyPayloadSeriesEntry{},
})
}
@@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med
}
}
}
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesTitle(s.ID, s.Title)
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{
title: s.Title,
posterURL: s.PosterURL,
backdropURL: s.BackdropURL,
found: true,
})
}
}
}
}
}
// payloadLibraryType resolves a library type through the request cache,
// payloadLibraryType resolves a library type through the request cache,
// falling back to a direct lookup when no cache is attached. found=false
// means the library row does not exist (soft-deleted or orphaned id).
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
@@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string)
// payloadSeriesTitle resolves a series title through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if title, hit := cache.seriesTitle(seriesID); hit {
return title, true, nil
}
var s model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
cache.setSeriesTitle(seriesID, "")
return "", true, nil
}
cache.setSeriesTitle(s.ID, s.Title)
return s.Title, true, nil
}
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
entry, ok, err := e.payloadSeriesEntry(ctx, seriesID)
if err != nil || !ok {
return "", false, err
}
if series == nil {
return "", false, nil
}
return series.Title, true, nil
return entry.title, true, nil
}
// payloadSeriesEntry resolves a series entry through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if entry, hit := cache.seriesEntry(seriesID); hit {
return entry, entry.found, nil
}
if e.repo != nil && e.repo.Series != nil {
s, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil || s == nil {
cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{})
return embyPayloadSeriesEntry{}, false, err
}
entry := embyPayloadSeriesEntry{
title: s.Title,
posterURL: s.PosterURL,
backdropURL: s.BackdropURL,
found: true,
}
cache.setSeriesEntry(seriesID, entry)
return entry, true, nil
}
}
if e.repo != nil && e.repo.Series != nil {
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
return embyPayloadSeriesEntry{}, false, err
}
if series == nil {
return embyPayloadSeriesEntry{}, false, nil
}
return embyPayloadSeriesEntry{
title: series.Title,
posterURL: series.PosterURL,
backdropURL: series.BackdropURL,
found: true,
}, true, nil
}
return embyPayloadSeriesEntry{}, false, nil
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
nfoPath := filepath.Join(dir, "竞女01.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<movie>
<title>竞女01</title>
<director>导演甲</director>
<actor><name>演员乙</name><role>主角</role></actor>
</movie>`
if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"})
if len(people) != 2 {
t.Fatalf("people=%d want 2: %#v", len(people), people)
}
got := map[string]string{}
for _, p := range people {
got[p["Name"].(string)] = p["Type"].(string)
}
if got["导演甲"] != "Director" || got["演员乙"] != "Actor" {
t.Fatalf("unexpected people: %#v", people)
}
}
func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyNFO, []byte(`<movie><director>旧导演</director></movie>`), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath})
if len(people) != 1 || people[0]["Name"] != "旧导演" {
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
}
}
func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/11/credits" {
http.NotFound(w, r)
return
}
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"cast": []map[string]any{
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
},
})
}))
defer server.Close()
svc := newTestEmbyService(t)
svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{
TMDbAPIKey: "test-key",
TMDbAPIProxy: server.URL,
TMDbImageProxy: "https://image.example/t/p",
}}, zap.NewNop(), nil))
media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11}
people := svc.resolveMediaPeople(t.Context(), media)
peopleAgain := svc.resolveMediaPeople(t.Context(), media)
if len(people) != 1 || len(peopleAgain) != 1 {
t.Fatalf("people=%#v again=%#v", people, peopleAgain)
}
if hits.Load() != 1 {
t.Fatalf("TMDb credits hits=%d want 1", hits.Load())
}
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
t.Fatalf("unexpected person: %#v", people[0])
}
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
if err != nil {
t.Fatal(err)
}
if raw != "https://image.example/t/p/w300/actor.jpg" {
t.Fatalf("person image URL=%q", raw)
}
}
+3 -3
View File
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
return out, nil
}
m, err := e.playableMedia(ctx, mediaID, userID)
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
return map[string]any{
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
}, nil
}
@@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
return map[string]any{
"Id": m.ID,
"Name": m.Title,
"Name": MediaVersionLabel(*m),
"Path": embyMediaSourcePath(m),
"Container": container,
"Size": m.SizeBytes,
+95 -1
View File
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
http *http.Client
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
cache *RuntimeCacheService
personMu sync.RWMutex
personImages map[string]embyRemotePersonImageRef
}
type embyRemotePersonImageRef struct {
accountID string
remoteID string
}
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
@@ -797,16 +805,30 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
// RemoteItem 拉取远程单条目详情(含响应的重写)。
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("item", mount.ID, remoteID)
var cached map[string]any
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
r.rememberRemotePeople(mount, cached)
return cached, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
var out map[string]any
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
return nil, err
}
r.rememberRemotePeople(mount, out)
RewriteEmbyRemoteIDs(out, mount.ID)
if cacheKey != "" && len(out) > 0 {
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
}
return out, nil
}
@@ -959,6 +981,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
"?api_key=" + url.QueryEscape(cfg.Token), nil
}
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
return
}
people := remotePeopleMaps(payload["People"])
if len(people) == 0 {
return
}
r.personMu.Lock()
defer r.personMu.Unlock()
if r.personImages == nil || len(r.personImages) > 20000 {
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
}
for _, person := range people {
name := strings.TrimSpace(remoteItemString(person, "Name"))
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
continue
}
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
accountID: mount.AccountID,
remoteID: remoteID,
}
}
}
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
if r == nil {
return "", false
}
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return "", false
}
r.personMu.RLock()
ref, ok := r.personImages[key]
r.personMu.RUnlock()
if !ok {
return "", false
}
acct := r.AccountByID(ctx, ref.accountID)
if acct == nil {
return "", false
}
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
if err != nil || strings.TrimSpace(raw) == "" {
return "", false
}
return raw, true
}
func remotePeopleMaps(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if person, ok := item.(map[string]any); ok {
out = append(out, person)
}
}
return out
default:
return nil
}
}
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
+5
View File
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
if items, ok := m["Items"]; ok {
RewriteEmbyRemoteIDs(items, accountID)
}
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
if people, ok := m["People"]; ok {
RewriteEmbyRemoteIDs(people, accountID)
}
}
+15
View File
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
"Items": []any{
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
},
"People": []any{
map[string]any{
"Id": "person-1",
"Name": "演员甲",
"Type": "Actor",
"PrimaryImageTag": "person-tag-1",
},
},
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
"MediaSources": []any{
map[string]any{
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
if nested["Id"] != "embyremote~acct-1~item-2" {
t.Fatalf("nested Id = %v", nested["Id"])
}
person := payload["People"].([]any)[0].(map[string]any)
if person["Id"] != "embyremote~acct-1~person-1" {
t.Fatalf("person Id = %v", person["Id"])
}
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
t.Fatalf("person display fields changed: %#v", person)
}
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
ms := payload["MediaSources"].([]any)[0].(map[string]any)
@@ -0,0 +1,82 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
http.NotFound(w, r)
return
}
requestedFields = r.URL.Query().Get("Fields")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "item-1",
"Name": "测试电影",
"People": []map[string]any{
{
"Id": "person-1",
"Name": "演员甲",
"Type": "Actor",
"PrimaryImageTag": "person-tag-1",
},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "user-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-people"},
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(requestedFields, "People") {
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
}
people, ok := out["People"].([]any)
if !ok || len(people) != 1 {
t.Fatalf("People = %#v, want one person", out["People"])
}
person := people[0].(map[string]any)
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
t.Fatalf("person Id = %v, want encoded remote id", got)
}
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
t.Fatalf("person display fields changed: %#v", person)
}
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
}
}
+17
View File
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
// 供调用方免二次请求读取 Type / SeriesId 等字段。
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
var cached struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
return cached.Media, cached.Raw, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, nil, err
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
}
RewriteEmbyRemoteIDs(out, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
if cacheKey != "" {
r.cache.SetJSON(ctx, cacheKey, struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
}
return &m, out, nil
}
+123 -5
View File
@@ -1,6 +1,7 @@
package service
import (
"sort"
"strings"
"time"
)
@@ -37,11 +38,7 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
if len(e.virtualSeries) > 2000 || len(e.virtualSeasons) > 5000 || len(e.virtualArtwork) > 7000 {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.trimVirtualCachesLocked(time.Now())
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
@@ -65,6 +62,7 @@ func (e *EmbyService) rememberSeasonGroup(season embySeasonGroup) {
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.trimVirtualCachesLocked(time.Now())
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
@@ -135,3 +133,123 @@ func (e *EmbyService) cachedArtworkURL(id, imageType string) (string, bool) {
}
return entry.backdrop, entry.backdrop != ""
}
// embyArtworkRef is the poster/backdrop pair stored next to a Latest payload
// so a JSON-cache hit can refill the in-memory artwork map before the client
// asks for the image.
type embyArtworkRef struct {
Primary string `json:"primary,omitempty"`
Backdrop string `json:"backdrop,omitempty"`
}
func (e *EmbyService) artworkRefsForSeriesGroups(groups []embySeriesGroup) map[string]embyArtworkRef {
if e == nil || len(groups) == 0 {
return nil
}
refs := make(map[string]embyArtworkRef, len(groups)*2)
for _, group := range groups {
if group.PosterURL == "" && group.BackdropURL == "" {
continue
}
ref := embyArtworkRef{Primary: group.PosterURL, Backdrop: group.BackdropURL}
refs[group.ID] = ref
for _, season := range e.seasonsForSeries(group) {
refs[season.ID] = ref
}
}
if len(refs) == 0 {
return nil
}
return refs
}
func (e *EmbyService) rememberArtworkRefs(refs map[string]embyArtworkRef) {
if e == nil || len(refs) == 0 {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(refs))
}
e.trimVirtualArtworkLocked(time.Now())
for id, ref := range refs {
if strings.TrimSpace(id) == "" || (ref.Primary == "" && ref.Backdrop == "") {
continue
}
e.virtualArtwork[id] = embyArtworkCacheEntry{primary: ref.Primary, backdrop: ref.Backdrop, expiresAt: expiresAt}
}
}
func (e *EmbyService) trimVirtualCachesLocked(now time.Time) {
e.trimVirtualSeriesLocked(now)
e.trimVirtualSeasonsLocked(now)
e.trimVirtualArtworkLocked(now)
}
func (e *EmbyService) trimVirtualSeriesLocked(now time.Time) {
for id, entry := range e.virtualSeries {
if now.After(entry.expiresAt) {
delete(e.virtualSeries, id)
}
}
evictOldest(e.virtualSeries, embyVirtualSeriesCap, func(entry embySeriesCacheEntry) time.Time {
return entry.expiresAt
})
}
func (e *EmbyService) trimVirtualSeasonsLocked(now time.Time) {
for id, entry := range e.virtualSeasons {
if now.After(entry.expiresAt) {
delete(e.virtualSeasons, id)
}
}
evictOldest(e.virtualSeasons, embyVirtualSeasonCap, func(entry embySeasonCacheEntry) time.Time {
return entry.expiresAt
})
}
func (e *EmbyService) trimVirtualArtworkLocked(now time.Time) {
for id, entry := range e.virtualArtwork {
if now.After(entry.expiresAt) {
delete(e.virtualArtwork, id)
}
}
evictOldest(e.virtualArtwork, embyVirtualArtworkCap, func(entry embyArtworkCacheEntry) time.Time {
return entry.expiresAt
})
}
// evictOldest drops the soonest-expiring entries until the map is under cap.
// It must not replace the map: a homepage refresh remembers many series at
// once, and wiping the whole cache made the just-advertised backdrops 404
// into a 1x1 placeholder.
func evictOldest[T any](items map[string]T, cap int, expiresAt func(T) time.Time) {
excess := len(items) - cap
if cap <= 0 || excess <= 0 {
return
}
type pair struct {
id string
at time.Time
}
ordered := make([]pair, 0, len(items))
for id, entry := range items {
ordered = append(ordered, pair{id: id, at: expiresAt(entry)})
}
sort.Slice(ordered, func(i, j int) bool { return ordered[i].at.Before(ordered[j].at) })
if excess > len(ordered) {
excess = len(ordered)
}
for i := 0; i < excess; i++ {
delete(items, ordered[i].id)
}
}
func embyVirtualImageTag(id, suffix string) string {
if strings.HasPrefix(id, embyVirtualSeriesPrefix) || strings.HasPrefix(id, embyVirtualSeasonPrefix) {
return id + suffix
}
return id
}
+120 -7
View File
@@ -156,6 +156,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) {
}
}
func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) {
if got := embyLatestSeriesRowLimit(25); got < 25*40 {
t.Fatalf("latest window %d is smaller than the 25x40 fixture", got)
}
}
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
@@ -229,7 +235,7 @@ func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &media, false, 0)
item := svc.itemPayload(t.Context(), &media, false, 0, false)
if tags, ok := item["ImageTags"].(map[string]string); !ok || tags["Primary"] != "ep-still" {
t.Fatalf("episode should expose a primary image tag: %#v", item["ImageTags"])
}
@@ -296,6 +302,71 @@ func TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) {
}
}
func TestEmbyVirtualSeriesArtworkRebuildsAfterMemoryDrop(t *testing.T) {
svc := newTestEmbyService(t)
svc.cache = NewRuntimeCacheService(nil, nil)
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
media := model.Media{
Base: model.Base{ID: "ep-hero"},
LibraryID: lib.ID,
Title: "树海之魔",
Path: `/media/anime/树海之魔/Season 01/树海之魔 - S01E01.mkv`,
PosterURL: `/poster.jpg`,
BackdropURL: `/backdrop.jpg`,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
items, err := svc.LatestItems(t.Context(), "", lib.ID, 5)
if err != nil {
t.Fatalf("latest items: %v", err)
}
if len(items) != 1 {
t.Fatalf("latest len = %d, want 1", len(items))
}
seriesID, _ := items[0]["Id"].(string)
tags, _ := items[0]["BackdropImageTags"].([]string)
if seriesID == "" || len(tags) != 1 || tags[0] != seriesID+embyVirtualBackdropTagSuffix {
t.Fatalf("hero item should advertise a cache-busted backdrop tag, got id=%q tags=%#v", seriesID, items[0]["BackdropImageTags"])
}
svc.virtualMu.Lock()
svc.virtualArtwork = nil
svc.virtualSeries = nil
svc.virtualSeasons = nil
svc.virtualMu.Unlock()
backdrop, err := svc.ImageURL(t.Context(), seriesID, "Backdrop")
if err != nil {
t.Fatalf("backdrop after memory drop: %v", err)
}
if backdrop != "/backdrop.jpg" {
t.Fatalf("backdrop = %q, want rebuilt backdrop", backdrop)
}
svc.virtualMu.Lock()
svc.virtualArtwork = nil
svc.virtualMu.Unlock()
if _, err := svc.LatestItems(t.Context(), "", lib.ID, 5); err != nil {
t.Fatalf("cached latest: %v", err)
}
cancelled, cancel := context.WithCancel(t.Context())
cancel()
backdrop, err = svc.ImageURL(cancelled, seriesID, "Backdrop")
if err != nil {
t.Fatalf("backdrop from rewarmed cache: %v", err)
}
if backdrop != "/backdrop.jpg" {
t.Fatalf("rewarmed backdrop = %q, want cached backdrop", backdrop)
}
}
func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true}
@@ -429,13 +500,13 @@ func TestInferSeriesNameFromPath(t *testing.T) {
want: "间谍过家家",
},
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
svc := newTestEmbyService(t)
@@ -513,3 +584,45 @@ func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
}
}
func TestEmbySeriesLibraryListUsesRuntimeCache(t *testing.T) {
svc := newTestEmbyService(t)
svc.cache = NewRuntimeCacheService(nil, nil)
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 1; i <= 2; i++ {
media := model.Media{
Base: model.Base{ID: fmt.Sprintf("cache-ep-%d", i)},
LibraryID: lib.ID,
Title: "缓存测试番",
Path: fmt.Sprintf(`/media/anime/缓存测试番/Season 01/缓存测试番.S01E%02d.mkv`, i),
SeasonNum: 1,
EpisodeNum: i,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
first, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
if err != nil {
t.Fatalf("first items call: %v", err)
}
if first["TotalRecordCount"] != 1 {
t.Fatalf("first series total = %#v, want 1", first["TotalRecordCount"])
}
if err := svc.repo.DB.Unscoped().Where("library_id = ?", lib.ID).Delete(&model.Media{}).Error; err != nil {
t.Fatalf("delete media: %v", err)
}
second, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
if err != nil {
t.Fatalf("second items call: %v", err)
}
items, _ := second["Items"].([]map[string]any)
if second["TotalRecordCount"] != 1 || len(items) != 1 {
t.Fatalf("cached series list = %#v, want the first response", second)
}
}
+42 -28
View File
@@ -5,38 +5,42 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
imageTags := map[string]string{}
backdropTags := []string{}
if group.PosterURL != "" {
imageTags["Primary"] = group.ID
imageTags["Primary"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
}
if group.BackdropURL != "" {
backdropTags = append(backdropTags, group.ID+"-bd")
if group.BackdropURL != "" || group.PosterURL != "" {
backdropTags = append(backdropTags, embyVirtualImageTag(group.ID, embyVirtualBackdropTagSuffix))
}
lastMediaAdded := group.DateLastMediaAdded
if lastMediaAdded.IsZero() {
lastMediaAdded = group.CreatedAt
}
item := map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
"Type": "Series",
"MediaType": "Video",
"IsFolder": true,
"ParentId": group.LibraryID,
"ProductionYear": group.Year,
"Overview": group.Overview,
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
lastMediaAdded := group.DateLastMediaAdded
if lastMediaAdded.IsZero() {
lastMediaAdded = group.CreatedAt
}
item := map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
"Type": "Series",
"MediaType": "Video",
"IsFolder": true,
"ParentId": group.LibraryID,
"ProductionYear": group.Year,
"Overview": group.Overview,
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"People": []map[string]any{},
"ProviderIds": map[string]string{
"Tmdb": intToStr(group.TMDbID),
"Bangumi": intToStr(group.BangumiID),
},
"UserData": emptyUserData(),
}
if group.PosterURL != "" {
item["PrimaryImageTag"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
}
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
@@ -48,12 +52,12 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
imageTags := map[string]string{}
backdropTags := []string{}
if season.Series.PosterURL != "" {
imageTags["Primary"] = season.ID
imageTags["Primary"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
}
if season.Series.BackdropURL != "" {
backdropTags = append(backdropTags, season.ID+"-bd")
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
backdropTags = append(backdropTags, embyVirtualImageTag(season.ID, embyVirtualBackdropTagSuffix))
}
return map[string]any{
item := map[string]any{
"Id": season.ID,
"Name": season.Name,
"ServerId": embyServerID,
@@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
"ChildCount": len(season.Episodes),
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"People": []map[string]any{},
"UserData": emptyUserData(),
}
if season.Series.PosterURL != "" {
item["PrimaryImageTag"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(season.Series.ID, embyVirtualPrimaryTagSuffix)
}
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
item["ParentBackdropItemId"] = season.Series.ID
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(season.Series.ID, embyVirtualBackdropTagSuffix)}
}
return item
}
+11 -3
View File
@@ -11,6 +11,10 @@ import (
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"PublishedServerUrl": "",
"WebSocketPortNumber": e.cfg.App.Port,
"WebSocketPortNumber": port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
"OperatingSystem": "Windows",
"LocalAddress": "",
"WanAddress": "",
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
+42 -8
View File
@@ -63,7 +63,15 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
return e.RecordProgressWithSession(ctx, userID, mediaID, positionTicks, runtimeTicks, "")
}
// RecordProgressWithSession records an Emby progress update together with its
// PlaySessionId. The server-issued ID contains a millisecond timestamp, which
// lets the repository reject reports from an older playback session.
func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64, playSessionID string) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
@@ -89,14 +97,16 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
}
}
}
completed := dur > 0 && pos >= dur*9/10
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
playSessionID = strings.TrimSpace(playSessionID)
err := e.repo.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(pos, dur),
SessionID: playSessionID,
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
})
if err == nil {
e.invalidateEmbyItemsCache(ctx)
@@ -104,6 +114,30 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
return err
}
// embyPlaySessionStartedAtMs extracts the millisecond timestamp embedded in a
// MeBox-issued PlaySessionId. Older IDs used seconds, so normalize those too.
func embyPlaySessionStartedAtMs(playSessionID string) int64 {
playSessionID = strings.TrimSpace(playSessionID)
if playSessionID == "" {
return 0
}
idx := strings.LastIndex(playSessionID, "-")
if idx < 0 || idx == len(playSessionID)-1 {
return 0
}
value, err := strconv.ParseInt(playSessionID[idx+1:], 10, 64)
if err != nil || value <= 0 {
return 0
}
if value >= 1_000_000_000 && value < 1_000_000_000_000 {
value *= 1000
}
if value < 1_000_000_000_000 {
return 0
}
return value
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
+1 -1
View File
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)[\\/][^\\/]*$`)
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
// 若没有季文件夹, 则退而去掉文件名取其父目录。
+143 -15
View File
@@ -6,6 +6,11 @@
// 1x02 / 01x02
// EP02 / E02
// 第2集 / 第02集
// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP])
//
// The "1x02" pattern only matches when it is not embedded in a larger number,
// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for
// season×episode (the old pattern read those as S20E108 / S40E216).
//
// For bare episode markers such as "EP02", the parser also looks at parent
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
@@ -14,6 +19,7 @@
package service
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
@@ -22,37 +28,64 @@ import (
)
var (
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`)
// patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。
// 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p]
// 自然不匹配。
patBracketEpisode = regexp.MustCompile(`\[0*(\d{1,3})\]`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
// patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。
patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
// Returns (0, 0) when nothing recognisable is found.
func ParseEpisode(path string) (season, episode int) {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 {
var found bool
season, found = seasonFromParents(path)
if !found {
season = 1
}
episode = mustAtoi(m[1])
return
}
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
return 0, mustAtoi(m[1])
}
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
var found bool
season, found = seasonFromParents(path)
@@ -90,13 +123,108 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
// when the name carries no such token.
//
// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO
// would inject the wrong identity back even after the parser was fixed.
func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) {
name := mediaSidecarBase(path)
if name == "" {
return 0, 0, false
}
dims := patResolutionDims.FindStringSubmatch(name)
if len(dims) < 3 {
return 0, 0, false
}
m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2])
if len(m) != 3 {
return 0, 0, false
}
return mustAtoi(m[1]), mustAtoi(m[2]), true
}
// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the
// episode title generated from it) that only exists because a resolution token
// was once mistaken for an SxxExx marker.
func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) {
if meta == nil {
return
}
season, episode, ok := resolutionEpisodeArtifact(mediaPath)
if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode {
return
}
// 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。
// 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。
if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 &&
parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum {
return
}
meta.SeasonNum = 0
meta.EpisodeNum = 0
if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) {
meta.EpisodeTitle = ""
}
}
// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" /
// "Episode N" form auto-written from an episode number rather than a real name.
func isGeneratedEpisodeTitle(title string, episode int) bool {
title = strings.TrimSpace(title)
if title == "" || episode <= 0 {
return false
}
for _, candidate := range []string{
fmt.Sprintf("第 %d 集", episode),
fmt.Sprintf("第%d集", episode),
fmt.Sprintf("第 %d 话", episode),
fmt.Sprintf("第%d话", episode),
fmt.Sprintf("第 %d 話", episode),
fmt.Sprintf("第%d話", episode),
fmt.Sprintf("Episode %d", episode),
fmt.Sprintf("EP%d", episode),
} {
if strings.EqualFold(title, candidate) {
return true
}
}
return false
}
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
// becomes S00E02. Normal episodes retain their parsed identity.
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
season, episode = ParseEpisode(path)
if episode != 0 {
return season, episode
}
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
specialEpisode := mustAtoi(m[1])
if specialEpisode > 0 {
return 0, specialEpisode
}
}
return season, episode
}
type episodeRef struct {
Season int
Episode int
}
func episodeRefsFromTitle(path string) []episodeRef {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if refs := parseSEpisodeRange(name); len(refs) > 0 {
return refs
}
+84
View File
@@ -29,7 +29,20 @@ func TestParseEpisode(t *testing.T) {
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
{`剧集/特别篇/03.mkv`, 0, 3},
{`剧集/剧集 - S00E04.mkv`, 0, 4},
{`动漫/摇曳露营/OVA/Season 3 [OVA01 [1080p].mkv`, 0, 1},
{`动漫/示例/OAD/示例.OAD02.mkv`, 0, 2},
{`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3},
{`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4},
{"Movie.2020.1080p.mkv", 0, 0},
// 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。
{"Movie.2020.1920x1080.mkv", 0, 0},
{"1920x1080.mkv", 0, 0},
{"[Group][Show][02][3840x2160].mkv", 1, 2},
// 字幕组方括号集号。
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1},
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12},
// 方括号里的年份/分辨率不是集号。
{"[Group][Show][2024][1080p].mkv", 0, 0},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -68,3 +81,74 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) {
}
}
}
func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) {
cases := []struct {
path string
wantSeason int
wantEpisode int
}{
{`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1},
{`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2},
{`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3},
{`动漫/路人女主/Specials/S00E04.mkv`, 0, 4},
}
for _, tc := range cases {
season, episode := onlineEpisodeIdentityFromPath(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode {
t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)",
tc.path, season, episode, tc.wantSeason, tc.wantEpisode)
}
}
}
func TestResolutionEpisodeArtifact(t *testing.T) {
cases := []struct {
path string
wantSeason int
wantEpisode int
wantOK bool
}{
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true},
{"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true},
{"Movie.2020.1080p.mkv", 0, 0, false},
{"Show.S01E02.mkv", 0, 0, false},
}
for _, tc := range cases {
season, episode, ok := resolutionEpisodeArtifact(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK {
t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)",
tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK)
}
}
}
func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
// 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。
polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"}
dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm")
if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" {
t.Fatalf("resolution artifact not dropped: %+v", polluted)
}
// 真实单集号不受影响。
real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"}
dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv")
if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" {
t.Fatalf("real episode identity was modified: %+v", real)
}
// 名称里没有分辨率时不改动任何值。
plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv")
if plain.SeasonNum != 20 || plain.EpisodeNum != 108 {
t.Fatalf("unrelated identity was cleared: %+v", plain)
}
// 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。
legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv")
if legit.SeasonNum != 20 || legit.EpisodeNum != 108 {
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
}
}
+7
View File
@@ -78,6 +78,13 @@ func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExterna
title, year := "", 0
if seriesLike {
title, year = CleanQuery(source)
} else if patTheatricalTitle.MatchString(raw) || patTheatricalFolder.MatchString(raw) {
base := pathBaseSlash(raw)
stem := mediaFileStem(base)
if stem == "" {
stem = strings.TrimSuffix(base, filepath.Ext(base))
}
title, year = CleanQuery(stem)
} else {
title, year = cloudSeriesTitleFromMediaPath(source)
if title == "" {
+23
View File
@@ -17,6 +17,7 @@ import (
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
@@ -35,6 +36,12 @@ type ImageProxy struct {
cacheDir string
mu sync.Mutex
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
// once causes CPU and memory spikes that make the whole UI feel sluggish.
resizeSemMu sync.Mutex
resizeSem chan struct{}
// libraryRootsFn returns the configured media library roots so that
// sidecar poster/artwork files stored alongside media (under arbitrary
// per-library paths) are allowed by isAllowedLocalPath. It is provided
@@ -55,6 +62,7 @@ type ImageProxy struct {
const (
imageBrowserCacheControl = "public, max-age=2592000, immutable"
imagePlaceholderCacheControl = "no-store"
imageMaxResizeConcurrency = 4
)
// NewImageProxy is the constructor.
@@ -64,6 +72,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
}
proxy.resizeSem = make(chan struct{}, imageResizeConcurrency())
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
// from image.tmdb.org via their HTTP proxy without extra config. On
@@ -181,6 +190,20 @@ func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
return p.allowedHostsCache[host]
}
// imageResizeConcurrency keeps decode/resize concurrency within the number
// of CPU threads the process is allowed to use, capped to avoid large
// temporary RGBA buffers on tiny hosts.
func imageResizeConcurrency() int {
n := runtime.GOMAXPROCS(0)
if n < 1 {
n = 1
}
if n > imageMaxResizeConcurrency {
n = imageMaxResizeConcurrency
}
return n
}
// Prune removes oldest cached images until disk usage is within the configured limit.
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
+1 -1
View File
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
}
ext := strings.ToLower(filepath.Ext(raw))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
return true
default:
return false
+55 -9
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -55,26 +56,32 @@ func (p *ImageProxy) RemoveFailed(raw string) error {
// Serve writes the requested image to w. Caller is expected to validate
// the JWT before invoking it.
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
// Emby 客户端用 maxWidth / maxHeight / quality 请求缩略图。不解析这些
// 参数就会把多兆字节的原图发给客户端,移动端往往在下载中途超时。
opts := parseImageResizeOptions(r)
if isLocalImagePath(raw) {
return p.serveLocalImage(w, r, raw)
return p.serveLocalImage(w, r, raw, opts)
}
return p.serveRemoteImage(ctx, w, r, raw)
return p.serveRemoteImage(ctx, w, r, raw, opts)
}
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string) error {
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
path := filepath.Clean(raw)
abs, err := filepath.Abs(path)
if err != nil || !p.isAllowedLocalPath(abs) {
servePlaceholder(w)
return nil
}
if opts.active() && p.serveResizedFromFile(w, r, abs, opts) {
return nil
}
if !serveImageFile(w, r, filepath.Base(abs), abs, imageBrowserCacheControl) {
servePlaceholder(w)
}
return nil
}
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
u, err := p.validateURL(raw)
if err != nil {
return err
@@ -83,14 +90,14 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
forceRefresh := r.URL.Query().Get("refresh") != ""
p.removeUnusableImageCache(cachePath, failPath)
if !forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
if err != nil {
if forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
}
if errors.Is(err, errImageProxyRequestSetup) {
@@ -100,6 +107,10 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
}
return nil
}
// 上游原图已落盘,缩放结果复用同一条缓存流水线。
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
return nil
}
w.Header().Set("Content-Type", ctype)
if contentLength != "" {
w.Header().Set("Content-Length", contentLength)
@@ -114,13 +125,48 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
return nil
}
// serveCachedImage 在远程原图已缓存时提供服务。请求带缩放参数时优先命中
// 缩放缓存,未命中则从已缓存的原图生成一份;缩放不可用时退回原图直出。
func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, key, cachePath string, opts imageResizeOptions) bool {
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
return true
}
return serveCachedImageFile(w, r, key, cachePath)
}
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
if err != nil {
return
}
ctype := detectContentType(data)
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
stat, err := file.Stat()
if err != nil || stat.IsDir() || stat.Size() <= 0 {
_ = file.Close()
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
headerSize := 512
if stat.Size() < int64(headerSize) {
headerSize = int(stat.Size())
}
header := make([]byte, headerSize)
n, readErr := io.ReadFull(file, header)
_ = file.Close()
if readErr != nil && readErr != io.ErrUnexpectedEOF {
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
header = header[:n]
// A transparent placeholder is exactly 67 bytes; checking the header alone
// is enough for the normal image cache entries (they are much larger but
// detectContentType only inspects the same leading 512 bytes anyway).
// Close the handle before deleting: Windows refuses to delete an open file.
if n > 0 && isImageContentType(detectContentType(header)) &&
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
return
}
_ = os.Remove(cachePath)
+10 -4
View File
@@ -13,6 +13,12 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
func TestIsLocalImagePathAcceptsLegacyImgSidecar(t *testing.T) {
if !isLocalImagePath(filepath.Join(t.TempDir(), "movie-poster.img")) {
t.Fatal("legacy .img sidecar should remain displayable")
}
}
var testJPEG = []byte{
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
// them to private/loopback IPs — blocking them broke legitimate posters.
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
}
}
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())

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