Compare commits

..

10 Commits

Author SHA1 Message Date
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
78 changed files with 3992 additions and 370 deletions
+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
+4 -1
View File
@@ -19,13 +19,16 @@ import (
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
+1 -1
View File
@@ -20,6 +20,7 @@ require (
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -88,7 +89,6 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
+6 -2
View File
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
+2
View File
@@ -7,6 +7,7 @@ const (
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
DefaultCacheMemoryMaxSizeMB = 128
)
func setDefaults(v *viper.Viper) {
@@ -44,6 +45,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
+3
View File
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mebox"
}
+5 -3
View File
@@ -113,10 +113,12 @@ type LoggingConfig struct {
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig 控制磁盘转码/刮削缓存。
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+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)
}
}
+35 -18
View File
@@ -29,24 +29,41 @@ var embyPlaceholderPNG = []byte{
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
}
}
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
}
}
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
if svc == nil || svc.Emby == nil {
embyServePlaceholderImage(c)
return
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
}
+4 -1
View File
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
nil,
)
wProgress := httptest.NewRecorder()
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
t.Fatalf("unexpected playback session metadata: %#v", hist)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
+32
View File
@@ -0,0 +1,32 @@
package handler
import "testing"
func TestIsEmbyPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{path: "/emby", want: true},
{path: "/emby/System/Info/Public", want: true},
{path: "//emby//Items//1", want: true},
{path: "/System/Info/Public", want: true},
{path: "/Search/Hints", want: true},
{path: "/Playback/BitrateTest", want: true},
{path: "/api/unknown", want: false},
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
{path: "/random/path", want: false},
}
for _, tt := range tests {
if got := IsEmbyPath(tt.path); got != tt.want {
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
if !changed || got != "/emby/api/stream/123" {
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
}
}
+87 -1
View File
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
"embywebsocket": {},
}
var embyRootSegments = map[string]struct{}{
"albums": {},
"artists": {},
"audio": {},
"audiocodecs": {},
"auth": {},
"branding": {},
"channels": {},
"collections": {},
"connect": {},
"containers": {},
"devices": {},
"displaypreferences": {},
"dlna": {},
"encoding": {},
"environment": {},
"gamegenres": {},
"games": {},
"genres": {},
"images": {},
"items": {},
"libraries": {},
"library": {},
"livestreams": {},
"livetv": {},
"localization": {},
"movies": {},
"musicgenres": {},
"news": {},
"notification": {},
"notifications": {},
"officialratings": {},
"packages": {},
"persons": {},
"playback": {},
"playlists": {},
"plugins": {},
"providers": {},
"reports": {},
"scheduledtasks": {},
"search": {},
"sessions": {},
"shows": {},
"songs": {},
"studios": {},
"subtitlecodecs": {},
"sync": {},
"system": {},
"tags": {},
"trailers": {},
"user_usage_stats": {},
"users": {},
"videocodecs": {},
"videos": {},
"years": {},
}
func isEmbyRootSegment(segment string) bool {
if _, ok := embyRootSegments[segment]; ok {
return true
}
_, ok := embyStaticSegments[segment]
return ok
}
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
// 例如 /System/Info/Public、/Users/Public、/Sessions。
func IsEmbyPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
path = multipleSlashesRE.ReplaceAllString(path, "/")
lower := strings.ToLower(path)
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return false
}
return isEmbyRootSegment(strings.ToLower(segments[0]))
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
@@ -0,0 +1,63 @@
package handler
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) {
imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatal(err)
}
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/t/p/w300/actor.jpg" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageData)
}))
defer imageServer.Close()
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: t.TempDir()},
Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"},
}
tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil)
emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb)
proxy := service.NewImageProxy(cfg, zap.NewNop())
imageURL, _ := url.Parse(imageServer.URL)
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} })
svc := &service.Container{Emby: emby, ImageProxy: proxy}
raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" {
t.Fatalf("resolved raw=%q err=%v", raw, err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "person~tmdb~101"},
{Key: "type", Value: "Primary"},
}
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil)
embyItemImageHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if got := len(w.Body.Bytes()); got != len(imageData) {
t.Fatalf("image bytes=%d want %d", got, len(imageData))
}
}
+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
}
+7
View File
@@ -128,6 +128,13 @@ func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container)
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
+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))
+33
View File
@@ -563,6 +563,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"))
+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) {
+15 -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
}
+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)
}
}
+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())
}
}
+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 将媒体项标记为给定用户的收藏。
+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)
}
}
+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
}
+35
View File
@@ -824,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)
}
+163 -5
View File
@@ -20,6 +20,8 @@ import (
"time"
"unicode"
"golang.org/x/sync/singleflight"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
@@ -136,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 {
@@ -147,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),
}
}
@@ -170,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{
@@ -257,8 +381,42 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{})
}
// FetchWithOptions 是 Fetch 的带偏好版本。
// 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
+78
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/glebarez/sqlite"
@@ -326,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))
}
+58 -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,59 @@ 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 等其余类型会回退到
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
+46
View File
@@ -61,6 +61,14 @@ type EmbyService struct {
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
peopleMu sync.RWMutex
peopleCache map[string]embyPeopleCacheEntry
tmdb *TMDbProvider
adult *AdultProvider
personImageMu sync.RWMutex
personImages map[string]string
}
// NewEmbyService is the constructor.
@@ -76,6 +84,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,8 +140,22 @@ 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 (
@@ -131,6 +170,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,
+15 -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,7 +52,8 @@ 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[:])
}
+194 -17
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
@@ -62,14 +64,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seasonPayload(season), nil
item := e.seasonPayload(season)
if media := seriesPeopleMedia(season.Series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
@@ -80,7 +90,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
return nil, nil
}
@@ -103,7 +117,7 @@ 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 带前缀)直接透传远程。
@@ -132,15 +146,16 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return cached.Items, nil
}
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)
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err == nil && e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, err
}
@@ -171,7 +186,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
return 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
}
@@ -180,7 +195,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
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
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
@@ -191,7 +206,7 @@ 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
}
// ResumeItems 列出有未完成播放进度的媒体。
@@ -255,7 +270,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) {
@@ -387,7 +402,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
}
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
}
continue
}
@@ -426,7 +441,7 @@ 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 {
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
@@ -499,7 +514,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"People": e.resolveMediaPeople(ctx, m),
"People": []map[string]any{},
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
@@ -519,21 +534,101 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
if seriesID != "" {
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
if sEntry.posterURL != "" {
item["SeriesPrimaryImageTag"] = seriesID
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
}
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
item["ParentBackdropItemId"] = seriesID
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
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{}
}
@@ -562,7 +657,6 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
people := make([]map[string]any, 0)
seen := make(map[string]bool)
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, _, err := decodeNFOFile(p)
@@ -612,6 +706,89 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
return people
}
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
if e == nil || strings.TrimSpace(key) == "" {
return nil, false
}
now := time.Now()
e.peopleMu.RLock()
entry, ok := e.peopleCache[key]
e.peopleMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.peopleMu.Lock()
delete(e.peopleCache, key)
e.peopleMu.Unlock()
}
return nil, false
}
out := make([]map[string]any, len(entry.people))
copy(out, entry.people)
return out, true
}
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
if e == nil || strings.TrimSpace(key) == "" {
return
}
e.peopleMu.Lock()
defer e.peopleMu.Unlock()
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
}
stored := make([]map[string]any, len(people))
copy(stored, people)
ttl := embyPeopleCacheTTL
if len(stored) == 0 {
ttl = embyPeopleEmptyCacheTTL
}
e.peopleCache[key] = embyPeopleCacheEntry{
people: stored,
expiresAt: time.Now().Add(ttl),
}
}
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
if e == nil || len(people) == 0 {
return
}
e.personImageMu.Lock()
defer e.personImageMu.Unlock()
if e.personImages == nil || len(e.personImages) > 20000 {
e.personImages = make(map[string]string, 128)
}
for _, person := range people {
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
e.personImages[id] = profilePath
}
}
}
func (e *EmbyService) cachedPersonImage(id string) string {
if e == nil {
return ""
}
e.personImageMu.RLock()
profilePath := e.personImages[strings.TrimSpace(id)]
e.personImageMu.RUnlock()
return profilePath
}
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
profilePath = strings.TrimSpace(profilePath)
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
return
}
e.personImageMu.Lock()
if e.personImages == nil {
e.personImages = make(map[string]string, 128)
}
e.personImages[strings.TrimSpace(id)] = profilePath
e.personImageMu.Unlock()
}
func embyPersonID(name, roleType string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
return "person-" + hex.EncodeToString(sum[:8])
+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
}
+64 -14
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),用于合并排序。
+52
View File
@@ -1,10 +1,17 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
@@ -55,3 +62,48 @@ func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
}
}
func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/11/credits" {
http.NotFound(w, r)
return
}
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"cast": []map[string]any{
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
},
})
}))
defer server.Close()
svc := newTestEmbyService(t)
svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{
TMDbAPIKey: "test-key",
TMDbAPIProxy: server.URL,
TMDbImageProxy: "https://image.example/t/p",
}}, zap.NewNop(), nil))
media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11}
people := svc.resolveMediaPeople(t.Context(), media)
peopleAgain := svc.resolveMediaPeople(t.Context(), media)
if len(people) != 1 || len(peopleAgain) != 1 {
t.Fatalf("people=%#v again=%#v", people, peopleAgain)
}
if hits.Load() != 1 {
t.Fatalf("TMDb credits hits=%d want 1", hits.Load())
}
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
t.Fatalf("unexpected person: %#v", people[0])
}
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
if err != nil {
t.Fatal(err)
}
if raw != "https://image.example/t/p/w300/actor.jpg" {
t.Fatalf("person image URL=%q", raw)
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
return out, nil
}
m, err := e.playableMedia(ctx, mediaID, userID)
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
return map[string]any{
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
}, nil
}
+83 -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 聚合服务。
@@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
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)
return out, nil
}
@@ -959,6 +969,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)
}
}
+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
}
+114 -7
View File
@@ -229,7 +229,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 +296,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 +494,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 +578,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)
}
}
+28 -28
View File
@@ -5,28 +5,28 @@ 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)),
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,
@@ -39,7 +39,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
"UserData": emptyUserData(),
}
if group.PosterURL != "" {
item["PrimaryImageTag"] = group.ID
item["PrimaryImageTag"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
}
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
item["PremiereDate"] = premiered
@@ -52,10 +52,10 @@ 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))
}
item := map[string]any{
"Id": season.ID,
@@ -75,12 +75,12 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
"UserData": emptyUserData(),
}
if season.Series.PosterURL != "" {
item["PrimaryImageTag"] = season.ID
item["SeriesPrimaryImageTag"] = season.Series.ID
item["PrimaryImageTag"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(season.Series.ID, embyVirtualPrimaryTagSuffix)
}
if season.Series.BackdropURL != "" {
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
item["ParentBackdropItemId"] = season.Series.ID
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(season.Series.ID, embyVirtualBackdropTagSuffix)}
}
return item
}
+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 {
+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 {
+30 -3
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -134,12 +135,38 @@ func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, ke
}
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)
+42 -5
View File
@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -203,6 +204,28 @@ func (p *ImageProxy) resizeCachePath(key string) string {
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
}
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
// the caller should fall back to the original image instead of blocking after
// the request has already been canceled.
func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
if p == nil {
return func() {}, true
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
case <-ctx.Done():
return nil, false
}
}
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
@@ -211,6 +234,25 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
if err != nil || stat.IsDir() || stat.Size() <= 0 {
return false
}
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
key := o.resizeCacheKey(srcPath, stat)
cachePath := p.resizeCachePath(key)
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
release, ok := p.acquireResizeSlot(r.Context())
if !ok {
return false
}
defer release()
// 等待并发槽期间,别的请求可能已经生成了同一张缩略图。
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
if err != nil {
return false
@@ -224,11 +266,6 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
}
key := o.resizeCacheKey(srcPath, stat)
cachePath := p.resizeCachePath(key)
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
p.writeResizeCache(cachePath, out)
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", imageBrowserCacheControl)
+44
View File
@@ -230,3 +230,47 @@ func TestServeResizedFromFileCachesScaledResult(t *testing.T) {
t.Fatal("expected a non-empty body")
}
}
func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
dir := t.TempDir()
mediaDir := dir + string(os.PathSeparator) + "media"
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
src := mediaDir + string(os.PathSeparator) + "poster.png"
original := encodeTestPNG(t, 529, 911, 255)
if err := os.WriteFile(src, original, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
opts := imageResizeOptions{MaxWidth: 400, Quality: 90}
first := httptest.NewRecorder()
if !proxy.serveResizedFromFile(first, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
t.Fatal("expected first call to be served")
}
stat, err := os.Stat(src)
if err != nil {
t.Fatalf("stat source: %v", err)
}
// Same size and mtime keep the resize cache key stable, but the source is
// now invalid image data. A correct implementation serves the cached
// thumbnail before reading/decoding the source again.
broken := bytes.Repeat([]byte{0}, len(original))
if err := os.WriteFile(src, broken, 0o644); err != nil {
t.Fatalf("overwrite source: %v", err)
}
if err := os.Chtimes(src, stat.ModTime(), stat.ModTime()); err != nil {
t.Fatalf("restore mtime: %v", err)
}
second := httptest.NewRecorder()
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
t.Fatal("expected second call to be served from resize cache")
}
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
t.Fatal("expected cached thumbnail to be reused without decoding the source")
}
}
@@ -55,7 +55,7 @@ func TestReadAdultLocalMetadataAndArtwork(t *testing.T) {
if got.PosterURL != poster || got.BackdropURL != fanart {
t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL)
}
if got.Genres != "剧情,中文字幕,测试片商,演员A" {
if got.Genres != "剧情,中文字幕,测试片商" {
t.Fatalf("genres = %q", got.Genres)
}
}
+3 -13
View File
@@ -61,7 +61,7 @@ func adultAwareGenres(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4)
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+4)
values = append(values, doc.Genres...)
values = append(values, doc.Tags...)
for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} {
@@ -69,18 +69,8 @@ func adultAwareGenres(doc *nfoDocument) []string {
values = append(values, cleanXMLText(value))
}
}
for _, value := range doc.Directors {
if cleanXMLText(value) != "" {
values = append(values, cleanXMLText(value))
}
}
for _, actor := range doc.Actors {
if cleanXMLText(actor.Name) != "" {
values = append(values, cleanXMLText(actor.Name))
} else if cleanXMLText(actor.Role) != "" {
values = append(values, cleanXMLText(actor.Role))
}
}
// Cast and crew are intentionally not folded into genres. They are read
// from NFO/TMDb on detail requests and kept only in the in-memory cache.
return values
}
+12
View File
@@ -32,6 +32,18 @@ func mediaReleaseOrderSQL(desc bool) string {
return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir)
}
// embyMediaReleaseSortTime matches the original payload-based ordering used by
// the Emby movie-library merge: release date, then year, then created_at.
func embyMediaReleaseSortTime(media model.Media) time.Time {
if t, ok := embyPremiereDate(media.ReleaseDate); ok {
return t
}
if media.Year > 0 {
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
return media.CreatedAt
}
func embyPremiereDate(value string) (time.Time, bool) {
value = normalizeReleaseDate(value)
if value == "" {
+16
View File
@@ -327,6 +327,22 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
return cards
}
// GroupMediaSeriesItems folds episode-level rows into one representative media
// row per series. It is intended for search surfaces where applying a small
// limit before series grouping would otherwise return several episodes from
// the same show.
func GroupMediaSeriesItems(items []model.Media) []model.Media {
cards := groupMediaSeriesCards(items)
if len(cards) == 0 {
return []model.Media{}
}
out := make([]model.Media, 0, len(cards))
for _, card := range cards {
out = append(out, card.Rep)
}
return out
}
func betterSeriesRepresentative(candidate, current model.Media) bool {
// A theatrical feature can have local poster.jpg/background.jpg files and
// therefore a higher artwork score than its TV episodes. Keep the TV row as
+61 -22
View File
@@ -276,6 +276,64 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
}
func metaTubePeople(movie *MetaTubeMovieInfo, enableActor bool) []map[string]any {
if movie == nil {
return nil
}
people := make([]map[string]any, 0, len(movie.Actors)+len(movie.Directors)+1)
add := func(name, personType string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
personID := embyPersonID(name, personType)
for _, existing := range people {
if existing["Id"] == personID {
return
}
}
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": personType,
"Role": personType,
})
}
if enableActor {
for _, actor := range movie.Actors {
add(actor, "Actor")
}
}
add(movie.Director, "Director")
for _, director := range movie.Directors {
add(director, "Director")
}
return people
}
func metaTubePeopleFromActors(actors []string) []map[string]any {
people := make([]map[string]any, 0, len(actors))
seen := map[string]bool{}
for _, actor := range actors {
actor = strings.TrimSpace(actor)
if actor == "" {
continue
}
personID := embyPersonID(actor, "Actor")
if seen[personID] {
continue
}
seen[personID] = true
people = append(people, map[string]any{
"Id": personID,
"Name": actor,
"Type": "Actor",
"Role": "Actor",
})
}
return people
}
func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match {
if res == nil {
return nil
@@ -293,13 +351,6 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
year := parseYearFromDate(res.ReleaseDate)
genres := make([]string, 0, len(res.Actors))
for _, a := range res.Actors {
if strings.TrimSpace(a) != "" {
genres = append(genres, strings.TrimSpace(a))
}
}
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
if posterURL == "" {
@@ -319,8 +370,8 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
Year: year,
ReleaseDate: cleanDateString(res.ReleaseDate),
Rating: res.Score,
Genres: genres,
NSFW: true,
People: metaTubePeopleFromActors(res.Actors),
DoubanID: res.ID, // 借用字段存储原始 ID 便于详情反查
TheTVDBID: res.Provider, // 借用字段存储 Provider
}
@@ -357,7 +408,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
}
}
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
genres := make([]string, 0, len(movie.Genres)+2)
for _, g := range movie.Genres {
if strings.TrimSpace(g) != "" {
genres = append(genres, strings.TrimSpace(g))
@@ -369,19 +420,6 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
if strings.TrimSpace(movie.Label) != "" && movie.Label != movie.Maker {
genres = append(genres, strings.TrimSpace(movie.Label))
}
for _, a := range movie.Actors {
if strings.TrimSpace(a) != "" {
genres = append(genres, strings.TrimSpace(a))
}
}
if strings.TrimSpace(movie.Director) != "" {
genres = append(genres, strings.TrimSpace(movie.Director))
}
for _, d := range movie.Directors {
if strings.TrimSpace(d) != "" {
genres = append(genres, strings.TrimSpace(d))
}
}
return &Match{
Provider: "metatube",
@@ -396,6 +434,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
Rating: movie.Score,
Genres: dedupeStrings(genres),
NSFW: true,
People: metaTubePeople(movie, cfg.EnableActor),
DoubanID: movie.ID,
TheTVDBID: movie.Provider,
}
+5 -2
View File
@@ -74,8 +74,11 @@ func TestMetaTubeProviderSearch(t *testing.T) {
if !m.NSFW {
t.Errorf("expected NSFW true")
}
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
t.Errorf("unexpected genres: %v", m.Genres)
if len(m.Genres) != 0 {
t.Errorf("actor names must not be persisted as genres: %v", m.Genres)
}
if len(m.People) != 1 || m.People[0]["Name"] != "相沢みなみ" || m.People[0]["Type"] != "Actor" {
t.Errorf("unexpected people: %#v", m.People)
}
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fcover.jpg"; m.PosterURL != want {
t.Errorf("poster URL = %q, want %q", m.PosterURL, want)
+51 -13
View File
@@ -41,24 +41,62 @@ func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackServ
// ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A
// position within 30 seconds of the duration auto-flags the item as
// completed so the home page can hide it from "Continue Watching".
// ProgressUpdate is a playback-progress report. SessionID, SessionStartedAtMs
// and Sequence are optional for compatibility with legacy callers.
type ProgressUpdate struct {
UserID string
MediaID string
PositionMs int64
DurationMs int64
SessionID string
SessionStartedAtMs int64
Sequence int64
}
// RecordProgress records an unversioned progress report. It is retained for
// older callers that do not provide playback-session metadata.
func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error {
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
completed := dur > 0 && position >= dur-30_000
h := &model.PlaybackHistory{
return p.RecordProgressUpdate(ctx, ProgressUpdate{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
DurationMs: duration,
})
}
// RecordProgressUpdate upserts the resume position for a (user, media) pair.
// When session metadata is present, stale reports are ignored so a delayed
// request cannot overwrite a newer position or completion state.
func (p *PlaybackService) RecordProgressUpdate(ctx context.Context, update ProgressUpdate) error {
if update.UserID == "" || update.MediaID == "" {
return errors.New("missing user or media")
}
return p.repo.History.Upsert(ctx, h)
dur := p.resolvePlaybackDuration(ctx, update.UserID, update.MediaID, update.DurationMs)
h := &model.PlaybackHistory{
UserID: update.UserID,
MediaID: update.MediaID,
PositionMs: update.PositionMs,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(update.PositionMs, dur),
SessionID: update.SessionID,
SessionStartedAtMs: update.SessionStartedAtMs,
Sequence: update.Sequence,
}
return p.repo.History.UpsertProgress(ctx, h)
}
// playbackProgressCompleted deliberately requires a positive position. For
// very short clips the 90% threshold avoids marking a zero-second sample as
// finished; otherwise the last 30 seconds are treated as completed.
func playbackProgressCompleted(position, duration int64) bool {
if position <= 0 || duration <= 0 {
return false
}
if duration <= 30_000 {
return position*10 >= duration*9
}
return position >= duration-30_000
}
// GetProgress returns the saved resume row for one media item, or nil when absent.
+22
View File
@@ -91,3 +91,25 @@ func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
}
}
func TestPlaybackProgressCompletedThreshold(t *testing.T) {
tests := []struct {
name string
position int64
duration int64
want bool
}{
{name: "zero duration", position: 10_000, duration: 0, want: false},
{name: "short clip early", position: 10_000, duration: 20_000, want: false},
{name: "short clip near end", position: 18_000, duration: 20_000, want: true},
{name: "movie before final window", position: 80_000, duration: 120_000, want: false},
{name: "movie in final window", position: 95_000, duration: 120_000, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := playbackProgressCompleted(tt.position, tt.duration); got != tt.want {
t.Fatalf("playbackProgressCompleted(%d, %d) = %t, want %t", tt.position, tt.duration, got, tt.want)
}
})
}
}
+327 -42
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"reflect"
"strings"
"sync"
"time"
@@ -13,20 +14,32 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
const (
defaultRuntimeCacheEntryLimit = 2048
runtimeCacheEntryOverheadBytes = 96
runtimeCacheMapEntryOverheadBytes = 64
runtimeCacheStringHeaderBytes = 16
runtimeCacheEstimateMaxDepth = 64
)
type RuntimeCacheService struct {
log *zap.Logger
client *redis.Client
prefix string
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
maxBytes int64
bytesUsed int64
}
type runtimeCacheItem struct {
raw []byte
expiresAt time.Time
lastUsed time.Time
size int64
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
@@ -37,10 +50,22 @@ type runtimeCacheItem struct {
type runtimeObjectItem struct {
value any
expiresAt time.Time
lastUsed time.Time
size int64
}
func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheService {
c := &RuntimeCacheService{log: log, memory: map[string]runtimeCacheItem{}, obj: map[string]runtimeObjectItem{}, limit: 2048}
maxBytes := int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
if cfg != nil && cfg.Cache.MemoryMaxSizeMB > 0 {
maxBytes = int64(cfg.Cache.MemoryMaxSizeMB) * 1024 * 1024
}
c := &RuntimeCacheService{
log: log,
memory: map[string]runtimeCacheItem{},
obj: map[string]runtimeObjectItem{},
limit: defaultRuntimeCacheEntryLimit,
maxBytes: maxBytes,
}
if cfg == nil {
return c
}
@@ -101,7 +126,7 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
if json.Unmarshal(raw, out) != nil {
return false
}
c.setMemory(fullKey, raw, 2*time.Second)
c.setMemoryOwned(fullKey, raw, 2*time.Second)
return true
}
}
@@ -117,7 +142,7 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
return
}
fullKey := c.key(key)
c.setMemory(fullKey, raw, ttl)
c.setMemoryOwned(fullKey, raw, ttl)
if c.client != nil {
_ = c.client.Set(ctx, fullKey, raw, ttl).Err()
}
@@ -130,41 +155,70 @@ func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
}
fullKey := c.key(key)
now := time.Now()
c.mu.RLock()
c.mu.Lock()
item, ok := c.obj[fullKey]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.obj, fullKey)
c.mu.Unlock()
return nil, false
}
if !now.Before(item.expiresAt) {
c.removeObjectLocked(fullKey)
c.mu.Unlock()
return nil, false
}
item.lastUsed = now
c.obj[fullKey] = item
c.mu.Unlock()
return item.value, true
}
// SetObject 存入一个此后视为不可变的对象。
// SetObject 存入一个此后视为不可变的对象。对象会按估算内存计入进程内缓存
// 总预算;单个对象超过预算时不会进入缓存,避免一次大列表请求再次打爆内存。
func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
}
fullKey := c.key(key)
size := estimateRuntimeCacheObjectSize(fullKey, value)
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.obj) >= c.limit {
for k, item := range c.obj {
if now.After(item.expiresAt) || len(c.obj) >= c.limit {
delete(c.obj, k)
}
if len(c.obj) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeObjectLocked(fullKey)
if !c.makeRoomLocked(now, size) {
return
}
c.obj[fullKey] = runtimeObjectItem{
value: value,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
// SetMaxSizeMB 热更新进程内缓存总预算。降低上限时会立即淘汰最久未使用的
// 条目;传 0 或负数时恢复默认预算。
func (c *RuntimeCacheService) SetMaxSizeMB(maxMB int) {
if c == nil {
return
}
if maxMB <= 0 {
maxMB = config.DefaultCacheMemoryMaxSizeMB
}
maxBytes := int64(maxMB) * 1024 * 1024
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.maxBytes = maxBytes
c.evictExpiredLocked(now)
for c.bytesUsed > c.maxBytes {
if !c.evictOldestLocked() {
break
}
}
c.obj[fullKey] = runtimeObjectItem{value: value, expiresAt: now.Add(ttl)}
}
func (c *RuntimeCacheService) DeletePrefix(ctx context.Context, prefix string) {
@@ -202,39 +256,145 @@ func (c *RuntimeCacheService) key(key string) string {
func (c *RuntimeCacheService) getMemory(key string) ([]byte, bool) {
now := time.Now()
c.mu.RLock()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.memory[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.memory, key)
c.mu.Unlock()
if !now.Before(item.expiresAt) {
c.removeMemoryLocked(key)
return nil, false
}
item.lastUsed = now
c.memory[key] = item
return item.raw, true
}
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
c.setMemoryBytes(key, raw, ttl, false)
}
func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Duration) {
c.setMemoryBytes(key, raw, ttl, true)
}
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
if ttl <= 0 || len(raw) == 0 {
return
}
size := int64(len(key)+len(raw)) + runtimeCacheEntryOverheadBytes
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.memory) >= c.limit {
now := time.Now()
for k, item := range c.memory {
if now.After(item.expiresAt) || len(c.memory) >= c.limit {
delete(c.memory, k)
}
if len(c.memory) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeMemoryLocked(key)
if !c.makeRoomLocked(now, size) {
return
}
if !owned {
raw = append([]byte(nil), raw...)
}
c.memory[key] = runtimeCacheItem{
raw: raw,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
func (c *RuntimeCacheService) makeRoomLocked(now time.Time, incoming int64) bool {
if incoming <= 0 {
incoming = 1
}
if c.limit <= 0 {
c.limit = defaultRuntimeCacheEntryLimit
}
if c.maxBytes <= 0 {
c.maxBytes = int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
}
if incoming > c.maxBytes {
return false
}
c.evictExpiredLocked(now)
for c.entryCountLocked() >= c.limit || c.bytesUsed+incoming > c.maxBytes {
if !c.evictOldestLocked() {
return false
}
}
c.memory[key] = runtimeCacheItem{raw: append([]byte(nil), raw...), expiresAt: time.Now().Add(ttl)}
return true
}
func (c *RuntimeCacheService) entryCountLocked() int {
return len(c.memory) + len(c.obj)
}
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
for key, item := range c.memory {
if !now.Before(item.expiresAt) {
c.removeMemoryLocked(key)
}
}
for key, item := range c.obj {
if !now.Before(item.expiresAt) {
c.removeObjectLocked(key)
}
}
}
func (c *RuntimeCacheService) evictOldestLocked() bool {
var (
oldestKey string
oldestKind byte
oldestAt time.Time
)
for key, item := range c.memory {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'm', item.lastUsed
}
}
for key, item := range c.obj {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'o', item.lastUsed
}
}
switch oldestKind {
case 'm':
c.removeMemoryLocked(oldestKey)
return true
case 'o':
c.removeObjectLocked(oldestKey)
return true
default:
return false
}
}
func (c *RuntimeCacheService) removeMemoryLocked(key string) {
item, ok := c.memory[key]
if !ok {
return
}
delete(c.memory, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) removeObjectLocked(key string) {
item, ok := c.obj[key]
if !ok {
return
}
delete(c.obj, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
@@ -242,12 +402,137 @@ func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
defer c.mu.Unlock()
for key := range c.memory {
if strings.HasPrefix(key, prefix) {
delete(c.memory, key)
c.removeMemoryLocked(key)
}
}
for key := range c.obj {
if strings.HasPrefix(key, prefix) {
delete(c.obj, key)
c.removeObjectLocked(key)
}
}
}
type runtimeCacheVisit struct {
kind reflect.Kind
ptr uintptr
}
func estimateRuntimeCacheObjectSize(key string, value any) int64 {
seen := make(map[runtimeCacheVisit]struct{})
size := int64(len(key)) + runtimeCacheEntryOverheadBytes
size += estimateRuntimeCacheReflectSize(reflect.ValueOf(value), seen, 0)
return size
}
func estimateRuntimeCacheReflectSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface:
if v.IsNil() {
return 0
}
return estimateRuntimeCacheReflectSize(v.Elem(), seen, depth+1)
case reflect.Pointer:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
return int64(v.Type().Elem().Size()) + estimateRuntimeCacheNestedSize(v.Elem(), seen, depth+1)
case reflect.String:
return int64(len(v.String())) + runtimeCacheStringHeaderBytes
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Cap())*int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Len())*runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(v.Type().Size())
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(v.Type().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return int64(v.Type().Size())
}
}
func estimateRuntimeCacheNestedSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
return estimateRuntimeCacheReflectSize(v, seen, depth+1)
case reflect.String:
return int64(len(v.String()))
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Cap()) * int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Len()) * runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(0)
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(0)
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return 0
}
}
func markRuntimeCacheVisit(v reflect.Value, seen map[runtimeCacheVisit]struct{}) bool {
ptr := v.Pointer()
if ptr == 0 {
return false
}
visit := runtimeCacheVisit{kind: v.Kind(), ptr: ptr}
if _, ok := seen[visit]; ok {
return true
}
seen[visit] = struct{}{}
return false
}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newRuntimeCacheForTest(t *testing.T, maxMB int) *RuntimeCacheService {
t.Helper()
cfg := &config.Config{}
cfg.Cache.MemoryMaxSizeMB = maxMB
return NewRuntimeCacheService(cfg, zap.NewNop())
}
func TestRuntimeCacheEvictsByByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("large", strings.Repeat("a", 800<<10), time.Minute)
cache.SetObject("small", strings.Repeat("b", 300<<10), time.Minute)
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("cache bytes=%d exceeds max=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("large"); ok {
t.Fatal("expected oldest large entry to be evicted")
}
if _, ok := cache.GetObject("small"); !ok {
t.Fatal("expected newest small entry to remain cached")
}
}
func TestRuntimeCacheSkipsOversizedObject(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("huge", strings.Repeat("x", 2<<20), time.Minute)
if _, ok := cache.GetObject("huge"); ok {
t.Fatal("oversized object must not be cached")
}
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d, want 0", cache.bytesUsed)
}
}
func TestRuntimeCachePrefixDeleteReleasesByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
for i := 0; i < 4; i++ {
cache.SetJSON(context.Background(), fmt.Sprintf("media:%d", i), strings.Repeat("x", 64<<10), time.Minute)
}
if cache.bytesUsed == 0 {
t.Fatal("expected cached bytes")
}
cache.DeletePrefix(context.Background(), "media:")
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d after prefix delete, want 0", cache.bytesUsed)
}
for i := 0; i < 4; i++ {
var out string
if cache.GetJSON(context.Background(), fmt.Sprintf("media:%d", i), &out) {
t.Fatalf("entry media:%d should have been deleted", i)
}
}
}
func TestRuntimeCacheStillEnforcesEntryLimit(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.limit = 3
for i := 0; i < 5; i++ {
cache.SetObject(fmt.Sprintf("item-%d", i), i, time.Minute)
}
if got := cache.entryCountLocked(); got != 3 {
t.Fatalf("entry count=%d, want 3", got)
}
}
func TestRuntimeCacheEstimatesMediaRows(t *testing.T) {
rows := []model.Media{{
Title: strings.Repeat("t", 1024),
Path: "/media/movies/example.mkv",
PosterURL: "https://image.example/poster.jpg",
BackdropURL: "https://image.example/backdrop.jpg",
Overview: strings.Repeat("o", 2048),
Genres: "Action,Adventure",
}}
size := estimateRuntimeCacheObjectSize("media:test", rows)
if size < int64(len(rows[0].Title)+len(rows[0].Overview)) {
t.Fatalf("estimated size %d is smaller than payload", size)
}
}
func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
cache.SetObject("old", strings.Repeat("a", 700<<10), time.Minute)
cache.SetObject("new", strings.Repeat("b", 700<<10), time.Minute)
if cache.bytesUsed <= 1<<20 {
t.Fatalf("test setup bytesUsed=%d, want >1MiB", cache.bytesUsed)
}
cache.SetMaxSizeMB(1)
if cache.maxBytes != 1<<20 {
t.Fatalf("maxBytes=%d, want 1MiB", cache.maxBytes)
}
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("bytesUsed=%d exceeds maxBytes=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("old"); ok {
t.Fatal("oldest entry should be evicted after lowering cache limit")
}
if _, ok := cache.GetObject("new"); !ok {
t.Fatal("newest entry should remain after lowering cache limit")
}
}
+16
View File
@@ -119,6 +119,22 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
}
cfg.Cache.ImagesMaxSizeMB = n
}
case "cache.memory_max_size_mb":
n := config.DefaultCacheMemoryMaxSizeMB
if value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return
}
n = parsed
}
if n <= 0 {
n = config.DefaultCacheMemoryMaxSizeMB
}
if n > 4096 {
n = 4096
}
cfg.Cache.MemoryMaxSizeMB = n
}
}
+19
View File
@@ -41,3 +41,22 @@ func TestApplyRuntimeSettingTranscodeSwitches(t *testing.T) {
t.Fatalf("max cpu threads = %d, want clamp 1", cfg.App.MaxCPUThreads)
}
}
func TestApplyRuntimeSettingMemoryCacheLimit(t *testing.T) {
cfg := &config.Config{}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "64")
if cfg.Cache.MemoryMaxSizeMB != 64 {
t.Fatalf("memory cache limit = %d, want 64", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "")
if cfg.Cache.MemoryMaxSizeMB != config.DefaultCacheMemoryMaxSizeMB {
t.Fatalf("empty value should restore default, got %d", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "99999")
if cfg.Cache.MemoryMaxSizeMB != 4096 {
t.Fatalf("memory cache limit should clamp to 4096, got %d", cfg.Cache.MemoryMaxSizeMB)
}
}
+81 -39
View File
@@ -8,53 +8,95 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// existingLocalMediaSnapshot 保留整库快照入口,主要供兼容性调用和测试使用。
// 实际扫库路径使用 existingLocalMediaSnapshotForRoot,避免多根目录库重复持有
// 与当前 root 无关的媒体记录。
func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) {
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
return s.existingLocalMediaSnapshotForRoot(ctx, libraryID, "", "")
}
// existingLocalMediaSnapshotForRoot 以流式方式读取当前 root 的已有媒体索引,
// 不再先构造 []model.Media 再复制成 map。map 中的字符串只在最终索引中保留一份,
// 可以显著降低大库扫描时的峰值内存。
func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context, libraryID, rootID, rootPath string) (map[string]existingLocalMedia, error) {
if s == nil || s.repo == nil || s.repo.DB == nil {
return map[string]existingLocalMedia{}, nil
}
libraryID = strings.TrimSpace(libraryID)
rootID = strings.TrimSpace(rootID)
rootPath = strings.TrimSpace(rootPath)
query := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%")
if rootID != "" {
// 兼容尚未回填 library_root_id 的旧数据;没有 root id 的行再由路径归属过滤。
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
}
rows, err := query.
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
Find(&rows).Error; err != nil {
Rows()
if err != nil {
return nil, err
}
snapshot := make(map[string]existingLocalMedia, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Path) == "" {
defer rows.Close()
snapshot := make(map[string]existingLocalMedia)
for rows.Next() {
var (
path string
row existingLocalMedia
)
if err := rows.Scan(
&path,
&row.LibraryRootID,
&row.RelativePath,
&row.Title,
&row.OriginalName,
&row.EpisodeTitle,
&row.SizeBytes,
&row.DurationSec,
&row.Width,
&row.Height,
&row.VideoCodec,
&row.AudioCodec,
&row.Container,
&row.STRMURL,
&row.FileID,
&row.PosterURL,
&row.BackdropURL,
&row.Overview,
&row.Year,
&row.ReleaseDate,
&row.Rating,
&row.TMDbID,
&row.BangumiID,
&row.DoubanID,
&row.TheTVDBID,
&row.SeasonNum,
&row.EpisodeNum,
&row.Genres,
&row.Countries,
&row.Languages,
&row.NSFW,
&row.ScrapeStatus,
); err != nil {
return nil, err
}
path = strings.TrimSpace(path)
if path == "" {
continue
}
snapshot[filepath.Clean(row.Path)] = existingLocalMedia{
LibraryRootID: row.LibraryRootID,
RelativePath: row.RelativePath,
Title: row.Title,
OriginalName: row.OriginalName,
EpisodeTitle: row.EpisodeTitle,
SizeBytes: row.SizeBytes,
DurationSec: row.DurationSec,
Width: row.Width,
Height: row.Height,
VideoCodec: row.VideoCodec,
AudioCodec: row.AudioCodec,
Container: row.Container,
STRMURL: row.STRMURL,
FileID: row.FileID,
PosterURL: row.PosterURL,
BackdropURL: row.BackdropURL,
Overview: row.Overview,
Year: row.Year,
ReleaseDate: row.ReleaseDate,
Rating: row.Rating,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
DoubanID: row.DoubanID,
TheTVDBID: row.TheTVDBID,
SeasonNum: row.SeasonNum,
EpisodeNum: row.EpisodeNum,
Genres: row.Genres,
Countries: row.Countries,
Languages: row.Languages,
NSFW: row.NSFW,
ScrapeStatus: row.ScrapeStatus,
if rootPath != "" {
if rootID == "" || strings.TrimSpace(row.LibraryRootID) == "" {
if !pathBelongsToRoot(path, rootPath) {
continue
}
}
}
snapshot[filepath.Clean(path)] = row
}
if err := rows.Err(); err != nil {
return nil, err
}
return snapshot, nil
}
@@ -59,3 +59,34 @@ func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) {
t.Fatalf("identity fields not preserved: %#v", row)
}
}
func TestExistingLocalMediaSnapshotForRootFiltersOtherRoots(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
root1 := t.TempDir()
root2 := t.TempDir()
path1 := filepath.Join(root1, "Root1.mkv")
path2 := filepath.Join(root2, "Root2.mkv")
if err := db.Create(&[]model.Media{
{LibraryID: "lib-1", LibraryRootID: "root-1", Path: path1, SizeBytes: 11},
{LibraryID: "lib-1", LibraryRootID: "root-2", Path: path2, SizeBytes: 22},
}).Error; err != nil {
t.Fatal(err)
}
got, err := scanner.existingLocalMediaSnapshotForRoot(t.Context(), "lib-1", "root-1", root1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got)
}
if _, ok := got[filepath.Clean(path1)]; !ok {
t.Fatalf("root-1 media missing from %#v", got)
}
if _, ok := got[filepath.Clean(path2)]; ok {
t.Fatalf("root-2 media leaked into root-1 snapshot: %#v", got)
}
}
+15 -7
View File
@@ -73,11 +73,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
res := &ScanResult{LibraryID: lib.ID}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
existingMedia = nil
}
roots, err := s.localLibraryScanRoots(ctx, lib)
if err != nil {
@@ -109,6 +104,15 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
continue
}
existingMedia, snapshotErr := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if snapshotErr != nil {
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(snapshotErr))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, &root, existingMedia, writeBatch, res)
if walkErr != nil {
addScanError(res, root.Path, walkErr)
@@ -147,9 +151,13 @@ func (s *ScannerService) scanLocalLibraryRoot(ctx context.Context, lib *model.Li
return res, err
}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
existingMedia, err := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(err))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, root, existingMedia, writeBatch, res)
+1 -1
View File
@@ -106,7 +106,7 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos)
b.c.DLNA = NewDLNAService(b.log)
b.c.Storage = NewStorageService(b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult)
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
+271
View File
@@ -0,0 +1,271 @@
package service
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
const (
tmdbPersonIDPrefix = "person~tmdb~"
tmdbProfileTagPrefix = "tmdb:"
tmdbProfileTagVersion = "p2"
tmdbPeopleMax = 80
)
var tmdbProfilePathRE = regexp.MustCompile(`^/[A-Za-z0-9._-]+$`)
type tmdbCreditCast struct {
ID int `json:"id"`
Name string `json:"name"`
Character string `json:"character"`
ProfilePath string `json:"profile_path"`
Order int `json:"order"`
Roles []struct {
Character string `json:"character"`
} `json:"roles"`
}
type tmdbCreditCrew struct {
ID int `json:"id"`
Name string `json:"name"`
Job string `json:"job"`
Department string `json:"department"`
ProfilePath string `json:"profile_path"`
Jobs []struct {
Job string `json:"job"`
} `json:"jobs"`
}
type tmdbCreditsResponse struct {
Cast []tmdbCreditCast `json:"cast"`
Crew []tmdbCreditCrew `json:"crew"`
}
// GetPeople fetches cast/crew for a movie or series from TMDb. Nothing is
// persisted; callers should keep the returned entries in a short-lived cache.
func (t *TMDbProvider) GetPeople(ctx context.Context, tmdbID int, mediaType string) ([]map[string]any, error) {
if t == nil || t.cfg == nil || tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if strings.TrimSpace(apiKey) == "" {
return nil, nil
}
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
endpoint := "/movie/" + strconv.Itoa(tmdbID) + "/credits"
if mediaType == "tv" {
endpoint = "/tv/" + strconv.Itoa(tmdbID) + "/aggregate_credits"
}
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
var out tmdbCreditsResponse
if err := t.getJSON(ctx, t.resolveBaseURL(ctx)+endpoint+"?"+q.Encode(), &out); err != nil {
return nil, err
}
people := make([]map[string]any, 0, 32)
seen := map[string]int{}
for _, cast := range out.Cast {
name := strings.TrimSpace(cast.Name)
if name == "" || cast.ID <= 0 {
continue
}
role := strings.TrimSpace(cast.Character)
if role == "" {
for _, item := range cast.Roles {
if role = strings.TrimSpace(item.Character); role != "" {
break
}
}
}
if role == "" {
role = "Actor"
}
person := newTMDbPerson(cast.ID, name, "Actor", role, cast.ProfilePath)
key := personIdentity(person)
if idx, ok := seen[key]; ok {
mergePersonRole(people[idx], role)
continue
}
seen[key] = len(people)
people = append(people, person)
if len(people) >= tmdbPeopleMax {
break
}
}
for _, crew := range out.Crew {
name := strings.TrimSpace(crew.Name)
if name == "" || crew.ID <= 0 {
continue
}
jobs := make([]string, 0, 1+len(crew.Jobs))
if strings.TrimSpace(crew.Job) != "" {
jobs = append(jobs, strings.TrimSpace(crew.Job))
}
for _, item := range crew.Jobs {
if job := strings.TrimSpace(item.Job); job != "" {
jobs = append(jobs, job)
}
}
jobs = deduplicateNonEmpty(jobs)
added := false
for _, job := range jobs {
personType := embyPersonTypeForJob(job)
if personType == "" {
continue
}
person := newTMDbPerson(crew.ID, name, personType, job, crew.ProfilePath)
key := personIdentity(person)
if idx, ok := seen[key]; ok {
mergePersonRole(people[idx], job)
} else if len(people) < tmdbPeopleMax {
seen[key] = len(people)
people = append(people, person)
}
added = true
}
if !added && len(people) >= tmdbPeopleMax {
break
}
}
return people, nil
}
func newTMDbPerson(personID int, name, personType, role, profilePath string) map[string]any {
person := map[string]any{
"Id": tmdbPersonID(personID),
"Name": name,
"Type": personType,
"Role": role,
}
if tag := tmdbProfileTag(profilePath); tag != "" {
person["PrimaryImageTag"] = tag
}
return person
}
func personIdentity(person map[string]any) string {
return fmt.Sprint(person["Id"]) + ":" + fmt.Sprint(person["Type"])
}
func mergePersonRole(person map[string]any, role string) {
role = strings.TrimSpace(role)
if role == "" {
return
}
existing := strings.TrimSpace(fmt.Sprint(person["Role"]))
if existing == "" {
person["Role"] = role
return
}
for _, part := range strings.Split(existing, " / ") {
if strings.EqualFold(strings.TrimSpace(part), role) {
return
}
}
person["Role"] = existing + " / " + role
}
func deduplicateNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
key := strings.ToLower(value)
if value == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, value)
}
return out
}
func embyPersonTypeForJob(job string) string {
switch strings.ToLower(strings.TrimSpace(job)) {
case "director":
return "Director"
case "writer", "screenplay", "story", "teleplay", "author":
return "Writer"
case "producer", "executive producer", "co-producer", "associate producer":
return "Producer"
case "composer", "original music composer", "music":
return "Composer"
default:
return ""
}
}
func tmdbPersonID(personID int) string {
return tmdbPersonIDPrefix + strconv.Itoa(personID)
}
func parseTMDbPersonID(id string) (int, bool) {
id = strings.TrimSpace(id)
if !strings.HasPrefix(id, tmdbPersonIDPrefix) {
return 0, false
}
raw := strings.TrimPrefix(id, tmdbPersonIDPrefix)
personID, err := strconv.Atoi(raw)
return personID, err == nil && personID > 0
}
func tmdbProfileTag(profilePath string) string {
profilePath = strings.TrimSpace(profilePath)
if !tmdbProfilePathRE.MatchString(profilePath) {
return ""
}
return tmdbProfileTagPrefix + profilePath + "?" + tmdbProfileTagVersion
}
func tmdbProfilePathFromTag(tag string) string {
tag = strings.TrimSpace(tag)
if !strings.HasPrefix(tag, tmdbProfileTagPrefix) {
return ""
}
profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix)
if idx := strings.IndexByte(profilePath, '?'); idx >= 0 {
profilePath = profilePath[:idx]
}
if !tmdbProfilePathRE.MatchString(profilePath) {
return ""
}
return profilePath
}
// ProfileImageURL builds the public TMDb image CDN URL for a profile path.
func (t *TMDbProvider) ProfileImageURL(profilePath string) string {
if t == nil || !tmdbProfilePathRE.MatchString(strings.TrimSpace(profilePath)) {
return ""
}
return strings.TrimRight(t.imgCDN, "/") + "/w300" + strings.TrimSpace(profilePath)
}
// PersonProfilePathByID resolves a person's current profile path directly
// from TMDb. Used when the client does not return the PrimaryImageTag.
func (t *TMDbProvider) PersonProfilePathByID(ctx context.Context, personID int) (string, error) {
if t == nil || t.cfg == nil || personID <= 0 {
return "", nil
}
apiKey := t.resolveAPIKey(ctx)
if strings.TrimSpace(apiKey) == "" {
return "", nil
}
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
endpoint := t.resolveBaseURL(ctx) + "/person/" + strconv.Itoa(personID) + "?" + q.Encode()
var out struct {
ProfilePath string `json:"profile_path"`
}
if err := t.getJSON(ctx, endpoint, &out); err != nil {
return "", err
}
return strings.TrimSpace(out.ProfilePath), nil
}
+83
View File
@@ -0,0 +1,83 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
func TestTMDbGetPeopleAndProfileImage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/11/credits" {
http.NotFound(w, r)
return
}
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},
},
"crew": []map[string]any{
{"id": 201, "name": "导演甲", "job": "Director", "department": "Directing", "profile_path": "/director.jpg"},
{"id": 202, "name": "编剧甲", "job": "Writer", "department": "Writing", "profile_path": "/writer.jpg"},
},
})
}))
defer server.Close()
cfg := &config.Config{Secrets: config.SecretsConfig{
TMDbAPIKey: "test-key",
TMDbAPIProxy: server.URL,
TMDbImageProxy: "https://image.example/t/p",
}}
provider := NewTMDbProvider(cfg, zap.NewNop(), nil)
people, err := provider.GetPeople(context.Background(), 11, "movie")
if err != nil {
t.Fatal(err)
}
if len(people) != 3 {
t.Fatalf("people=%d want 3: %#v", len(people), people)
}
if people[0]["Id"] != "person~tmdb~101" || people[0]["Role"] != "主角" {
t.Fatalf("unexpected actor: %#v", people[0])
}
if people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
t.Fatalf("unexpected actor image tag: %#v", people[0])
}
if got := provider.ProfileImageURL("/actor.jpg"); got != "https://image.example/t/p/w300/actor.jpg" {
t.Fatalf("profile image URL=%q", got)
}
}
func TestTMDbGetPeopleUsesAggregateCreditsForTV(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tv/22/aggregate_credits" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"cast": []map[string]any{
{"id": 301, "name": "演员乙", "roles": []map[string]any{{"character": "角色乙"}}, "profile_path": "/tv-actor.jpg"},
},
"crew": []map[string]any{},
})
}))
defer server.Close()
cfg := &config.Config{Secrets: config.SecretsConfig{TMDbAPIKey: "test-key", TMDbAPIProxy: server.URL}}
provider := NewTMDbProvider(cfg, zap.NewNop(), nil)
people, err := provider.GetPeople(context.Background(), 22, "tv")
if err != nil {
t.Fatal(err)
}
if len(people) != 1 || people[0]["Role"] != "角色乙" {
t.Fatalf("unexpected TV people: %#v", people)
}
}
+1
View File
@@ -22,6 +22,7 @@ type Match struct {
Countries []string `json:"countries,omitempty"`
Genres []string `json:"genres,omitempty"`
Aliases []string `json:"aliases,omitempty"`
People []map[string]any `json:"-"`
NSFW bool `json:"nsfw,omitempty"`
SearchKeyword string `json:"-"`
}
+29
View File
@@ -174,6 +174,35 @@ export function stopHLSJob(mediaId: string): void {
}).catch(() => undefined)
}
// postPlaybackProgressKeepalive sends the final playback position without
// relying on an axios request surviving page navigation or tab close.
export function postPlaybackProgressKeepalive(payload: {
media_id: string
position_ms: number
duration_ms: number
session_id?: string
session_started_at_ms?: number
sequence?: number
}): void {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = useAuthStore.getState().token
if (token) headers.Authorization = `Bearer ${token}`
const activeProfileId = getActivePlayProfileId()
if (activeProfileId) {
headers['X-Play-Profile-ID'] = activeProfileId
const pinToken = getActivePlayProfilePinToken()
if (pinToken) headers['X-Play-Profile-PIN-Token'] = pinToken
}
void fetch('/api/history', {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
headers,
body: JSON.stringify(payload),
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
// can never be blocked by CORS / GFW. Empty strings pass through unchanged.
export type ImageURLOptions =
+12 -3
View File
@@ -193,10 +193,18 @@ export const mediaAPI = {
recent: (limit = 24) =>
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
search: (q: string, limit = 50) =>
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
search: (q: string, limit = 50, options?: { groupSeries?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: {
q,
limit,
...(options?.groupSeries ? { group_series: 1 } : {}),
},
})
.then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: {
@@ -204,6 +212,7 @@ export const mediaAPI = {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
...(options?.groupSeries ? { group_series: 1 } : {}),
},
timeout: LONG_REQUEST_TIMEOUT,
})
+11 -8
View File
@@ -27,6 +27,15 @@ export interface ExternalPlayer {
url: string
}
export interface PlaybackProgressRequest {
media_id: string
position_ms: number
duration_ms: number
session_id?: string
session_started_at_ms?: number
sequence?: number
}
function publicOriginHeader() {
if (typeof window === 'undefined' || !window.location?.origin) return undefined
return { 'X-MeBox-Public-Origin': window.location.origin }
@@ -38,14 +47,8 @@ export const playbackAPI = {
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.then((r) => r.data),
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
.post('/history', {
media_id: mediaId,
position_ms: positionMs,
duration_ms: durationMs,
})
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data),
recentHistory: () =>
api.get<{ items: HistoryItem[] }>('/history').then((r) => r.data.items),
+168 -34
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { imageURL } from '../api/client'
@@ -108,43 +108,99 @@ export function LayoutHeader({
)
}
const SEARCH_PAGE_SIZE = 8
function LayoutHeaderSearch() {
const [query, setQuery] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [results, setResults] = useState<Media[]>([])
const [hasMore, setHasMore] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const isOpenRef = useRef(false)
const suppressInputRef = useRef(false)
// 递增序号守卫:快速连续输入时丢弃过期响应
const searchSeqRef = useRef(0)
const pageRef = useRef(0)
const loadingMoreRef = useRef(false)
const resultsRef = useRef<Media[]>([])
const hasMoreRef = useRef(false)
const navigate = useNavigate()
const location = useLocation()
const locationKey = `${location.pathname}${location.search}`
const prevLocationKeyRef = useRef(locationKey)
const setSearchOpen = (open: boolean) => {
isOpenRef.current = open
setIsOpen(open)
}
const dismissSearch = (clearQuery: boolean) => {
suppressInputRef.current = true
setSearchOpen(false)
if (clearQuery) {
searchSeqRef.current += 1
pageRef.current = 0
loadingMoreRef.current = false
resultsRef.current = []
hasMoreRef.current = false
setQuery('')
setResults([])
setLoading(false)
setLoadingMore(false)
setHasMore(false)
}
inputRef.current?.blur()
window.setTimeout(() => {
suppressInputRef.current = false
}, 0)
}
const dismissSearchRef = useRef(dismissSearch)
dismissSearchRef.current = dismissSearch
useEffect(() => {
const trimmed = query.trim()
if (!trimmed) {
searchSeqRef.current += 1
pageRef.current = 0
loadingMoreRef.current = false
resultsRef.current = []
hasMoreRef.current = false
setResults([])
setLoading(false)
setLoadingMore(false)
setHasMore(false)
isOpenRef.current = false
setIsOpen(false)
return
}
const seq = ++searchSeqRef.current
pageRef.current = 0
loadingMoreRef.current = false
resultsRef.current = []
hasMoreRef.current = false
setResults([])
setHasMore(false)
setLoading(true)
setLoadingMore(false)
const timer = setTimeout(async () => {
try {
const res = await mediaAPI.search(trimmed, 8)
const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true })
if (seq !== searchSeqRef.current) return
setResults(res.items || [])
if (isOpenRef.current) setIsOpen(true)
const items = res.items || []
const total = res.total ?? items.length
const more = items.length < total
pageRef.current = 1
resultsRef.current = items
hasMoreRef.current = more
setResults(items)
setHasMore(more)
if (isOpenRef.current && !suppressInputRef.current) setIsOpen(true)
} catch {
// 请求失败时保留旧结果,避免网络抖动清空下拉
// 请求失败时保持空结果,用户继续输入或滚动时会重新请求
} finally {
if (seq === searchSeqRef.current) setLoading(false)
}
@@ -153,26 +209,67 @@ function LayoutHeaderSearch() {
return () => clearTimeout(timer)
}, [query])
useLayoutEffect(() => {
if (prevLocationKeyRef.current === locationKey) return
prevLocationKeyRef.current = locationKey
dismissSearchRef.current(true)
}, [locationKey])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
isOpenRef.current = false
setIsOpen(false)
}
function handlePointerDown(e: PointerEvent) {
if (!isOpenRef.current) return
const root = containerRef.current
if (root && e.composedPath().includes(root)) return
dismissSearchRef.current(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [])
const loadMore = async () => {
const trimmed = query.trim()
if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return
const seq = searchSeqRef.current
const nextPage = pageRef.current + 1
loadingMoreRef.current = true
setLoadingMore(true)
try {
const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true })
if (seq !== searchSeqRef.current) return
const incoming = res.items || []
const currentResults = resultsRef.current
const known = new Set(currentResults.map((item) => item.id))
const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))]
const total = res.total ?? nextResults.length
const more = nextPage * SEARCH_PAGE_SIZE < total
pageRef.current = nextPage
resultsRef.current = nextResults
hasMoreRef.current = more
setResults(nextResults)
setHasMore(more)
} catch {
// 请求失败时保留当前结果,继续滚动可重试
} finally {
if (seq === searchSeqRef.current) {
loadingMoreRef.current = false
setLoadingMore(false)
}
}
}
const handleSelect = (item: Media) => {
setSearchOpen(false)
setQuery('')
navigate(favouriteMediaLink(item))
if (suppressInputRef.current) return
const to = favouriteMediaLink(item)
dismissSearch(true)
navigate(to)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSearchOpen(false)
dismissSearch(false)
} else if (e.key === 'Enter' && results.length > 0) {
handleSelect(results[0])
}
@@ -186,14 +283,21 @@ function LayoutHeaderSearch() {
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
if (suppressInputRef.current) return
const nextQuery = e.target.value
setQuery(nextQuery)
setSearchOpen(Boolean(nextQuery.trim()))
if (nextQuery.trim() && document.activeElement === inputRef.current) {
setSearchOpen(true)
} else if (!nextQuery.trim()) {
setSearchOpen(false)
}
}}
onFocus={() => {
if (suppressInputRef.current) return
if (query.trim()) setSearchOpen(true)
}}
onKeyDown={handleKeyDown}
@@ -206,9 +310,7 @@ function LayoutHeaderSearch() {
<button
type="button"
onClick={() => {
setQuery('')
setResults([])
setSearchOpen(false)
dismissSearch(true)
}}
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
>
@@ -218,16 +320,32 @@ function LayoutHeaderSearch() {
</div>
{/* Search Dropdown Results */}
<AnimatePresence>
{isOpen && query.trim() && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
{isOpen && query.trim() && typeof document !== 'undefined'
? createPortal(
<div
aria-hidden="true"
className="fixed inset-0 z-[25]"
onPointerDown={() => dismissSearch(false)}
/>,
document.body,
)
: null}
{isOpen && query.trim() ? (
<div
onScroll={(e) => {
const target = e.currentTarget
if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) {
void loadMore()
}
}}
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
>
{results.length === 0 && !loading ? (
{results.length === 0 && loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
正在搜索…
</div>
) : results.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
未搜索到与 “{query}” 相关的媒体内容
</div>
@@ -236,6 +354,12 @@ function LayoutHeaderSearch() {
{results.map((item) => (
<button
key={item.id}
type="button"
onPointerDown={(e) => {
if (e.button !== 0) return
e.preventDefault()
handleSelect(item)
}}
onClick={() => handleSelect(item)}
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
@@ -282,11 +406,21 @@ function LayoutHeaderSearch() {
</div>
</button>
))}
{loadingMore && (
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
<LoaderCircle size={12} className="text-brand-500 animate-spin" />
正在加载更多…
</div>
)}
{!loadingMore && !hasMore && results.length > 0 && (
<div className="py-2 text-center text-[10px] text-[var(--app-muted)]">
已显示全部 {results.length} 条结果
</div>
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
) : null}
</div>
)
}
+72 -19
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { hlsURL, stopHLSJob, streamURL } from '../api/client'
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -48,6 +48,26 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
type PlaybackProgressSession = {
mediaId: string
id: string
startedAtMs: number
sequence: number
}
function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
const randomID =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
mediaId,
id: `${mediaId}:${randomID}`,
startedAtMs: Date.now(),
sequence: 0,
}
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -57,6 +77,7 @@ export function PlayerPage() {
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -565,41 +586,73 @@ export function PlayerPage() {
modeRef.current = mode
}, [hlsStartSec, mode])
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
// Persist resume position every 10 seconds while playing, and immediately upon
// pause/page hide/unmount. Bind after mediaId is available because
// PlayerVideoStage does not render the <video> element until media is loaded.
useEffect(() => {
if (!id || !ref.current) return
if (!id || !mediaId || !ref.current) return
const video = ref.current
if (progressSessionRef.current?.mediaId !== mediaId) {
progressSessionRef.current = newPlaybackProgressSession(mediaId)
lastSentRef.current = 0
}
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
return Math.floor((currentStartSec + (video.currentTime || 0)) * 1000)
const absolute = currentStartSec + (video.currentTime || 0)
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
return Math.floor(Math.max(mediaDur, currentStartSec + (video.duration || 0)) * 1000)
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
}
const handler = () => {
const send = (keepalive: boolean) => {
const currentMedia = mediaRef.current
if (!currentMedia || currentMedia.id !== mediaId) return
const now = Date.now()
if (now - lastSentRef.current < 10_000) return
if (!keepalive && now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = absolutePositionMs()
if (positionMs <= 0) return
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
const session = progressSessionRef.current
if (!session || session.mediaId !== mediaId) return
session.sequence += 1
const payload = {
media_id: currentMedia.id,
position_ms: positionMs,
duration_ms: durationMs,
session_id: session.id,
session_started_at_ms: session.startedAtMs,
sequence: session.sequence,
}
if (keepalive) {
postPlaybackProgressKeepalive(payload)
return
}
playbackAPI.recordProgress(payload).catch(() => undefined)
}
video.addEventListener('timeupdate', handler)
video.addEventListener('pause', handler)
const onTimeUpdate = () => send(false)
const onPause = () => send(true)
const onPageHide = () => send(true)
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') send(true)
}
video.addEventListener('timeupdate', onTimeUpdate)
video.addEventListener('pause', onPause)
window.addEventListener('pagehide', onPageHide)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
video.removeEventListener('timeupdate', onTimeUpdate)
video.removeEventListener('pause', onPause)
window.removeEventListener('pagehide', onPageHide)
document.removeEventListener('visibilitychange', onVisibilityChange)
send(true)
}
}, [id])
}, [id, mediaId])
// 加载剧集/播放列表
useEffect(() => {
+8
View File
@@ -99,6 +99,14 @@ export const generalSettingsGroup: SettingGroup = {
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
defaultValue: '1',
},
{
key: 'cache.memory_max_size_mb',
label: '进程内缓存上限 (MB)',
type: 'number',
hint: 'JSON/列表等热缓存共用的内存上限。低配机器建议 32-64,内存充足可用 128-256;保存后会立即裁剪超额缓存,默认 128MB',
defaultValue: '128',
placeholder: '128',
},
{
key: 'cache.images_max_size_mb',
label: '图片缓存上限 (MB)',