mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 386fc358d1 | |||
| 2989649445 |
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
|
||||
}
|
||||
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
|
||||
}
|
||||
|
||||
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
|
||||
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
|
||||
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
|
||||
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
|
||||
encoderCfg := zap.NewProductionEncoderConfig()
|
||||
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
var encoder zapcore.Encoder
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderCfg)
|
||||
} else {
|
||||
encoder = zapcore.NewJSONEncoder(encoderCfg)
|
||||
}
|
||||
|
||||
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log := zap.New(
|
||||
zapcore.NewCore(encoder, writer, zap.InfoLevel),
|
||||
zap.AddCaller(),
|
||||
zap.AddStacktrace(zapcore.ErrorLevel),
|
||||
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
|
||||
)
|
||||
return log, func() { _ = writer.Close() }, nil
|
||||
}
|
||||
|
||||
func embyCompatLogPath(cfg *config.Config) string {
|
||||
out := strings.TrimSpace(cfg.Logging.OutputPath)
|
||||
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
|
||||
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
|
||||
}
|
||||
if ext := filepath.Ext(out); ext != "" {
|
||||
base := strings.TrimSuffix(out, ext)
|
||||
return base + ".emby-compat" + ext
|
||||
}
|
||||
return filepath.Join(out, "emby-compat.log")
|
||||
}
|
||||
|
||||
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
|
||||
}
|
||||
_ = writer.Sync()
|
||||
}
|
||||
|
||||
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Logging.Format = "json"
|
||||
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
||||
cfg.Logging.EnableRotation = true
|
||||
cfg.Logging.MaxSizeMB = 1
|
||||
cfg.Logging.MaxBackups = 2
|
||||
|
||||
log, closeFn, err := newEmbyCompatLogger(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log.Info("emby request", zap.String("client", "Infuse"))
|
||||
_ = log.Sync()
|
||||
closeFn()
|
||||
|
||||
path := filepath.Join(dir, "logs", "emby-compat.log")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
|
||||
t.Fatalf("dedicated Emby log missing request data: %s", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Cache.MediaTTLSeconds != 90 {
|
||||
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
|
||||
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
|
||||
}
|
||||
if cfg.Search.Index != "mebox_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
|
||||
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
|
||||
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
|
||||
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
|
||||
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
|
||||
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
|
||||
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
||||
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
|
||||
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
|
||||
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
|
||||
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
|
||||
}
|
||||
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
|
||||
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
|
||||
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
|
||||
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
|
||||
}
|
||||
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
|
||||
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
defaultDatabaseMaxIdleConns = 4
|
||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||
DefaultCacheMemoryMaxSizeMB = 128
|
||||
)
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
@@ -44,6 +45,7 @@ func setDefaults(v *viper.Viper) {
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
|
||||
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
|
||||
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||
c.Cache.ImagesMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.MemoryMaxSizeMB <= 0 {
|
||||
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
|
||||
}
|
||||
if c.Cache.RedisPrefix == "" {
|
||||
c.Cache.RedisPrefix = "mebox"
|
||||
}
|
||||
|
||||
@@ -113,10 +113,12 @@ type LoggingConfig struct {
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
}
|
||||
|
||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
|
||||
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
|
||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||
TTLHours int `mapstructure:"ttl_hours"`
|
||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||
progressReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
|
||||
nil,
|
||||
)
|
||||
wProgress := httptest.NewRecorder()
|
||||
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
if hist.PositionMs != 30000 {
|
||||
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
||||
}
|
||||
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
|
||||
t.Fatalf("unexpected playback session metadata: %#v", hist)
|
||||
}
|
||||
|
||||
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||
resumableReq := httptest.NewRequest(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/emby", want: true},
|
||||
{path: "/emby/System/Info/Public", want: true},
|
||||
{path: "//emby//Items//1", want: true},
|
||||
{path: "/System/Info/Public", want: true},
|
||||
{path: "/Search/Hints", want: true},
|
||||
{path: "/Playback/BitrateTest", want: true},
|
||||
{path: "/api/unknown", want: false},
|
||||
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
|
||||
{path: "/random/path", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsEmbyPath(tt.path); got != tt.want {
|
||||
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
|
||||
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
|
||||
if !changed || got != "/emby/api/stream/123" {
|
||||
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
var embyRootSegments = map[string]struct{}{
|
||||
"albums": {},
|
||||
"artists": {},
|
||||
"audio": {},
|
||||
"audiocodecs": {},
|
||||
"auth": {},
|
||||
"branding": {},
|
||||
"channels": {},
|
||||
"collections": {},
|
||||
"connect": {},
|
||||
"containers": {},
|
||||
"devices": {},
|
||||
"displaypreferences": {},
|
||||
"dlna": {},
|
||||
"encoding": {},
|
||||
"environment": {},
|
||||
"gamegenres": {},
|
||||
"games": {},
|
||||
"genres": {},
|
||||
"images": {},
|
||||
"items": {},
|
||||
"libraries": {},
|
||||
"library": {},
|
||||
"livestreams": {},
|
||||
"livetv": {},
|
||||
"localization": {},
|
||||
"movies": {},
|
||||
"musicgenres": {},
|
||||
"news": {},
|
||||
"notification": {},
|
||||
"notifications": {},
|
||||
"officialratings": {},
|
||||
"packages": {},
|
||||
"persons": {},
|
||||
"playback": {},
|
||||
"playlists": {},
|
||||
"plugins": {},
|
||||
"providers": {},
|
||||
"reports": {},
|
||||
"scheduledtasks": {},
|
||||
"search": {},
|
||||
"sessions": {},
|
||||
"shows": {},
|
||||
"songs": {},
|
||||
"studios": {},
|
||||
"subtitlecodecs": {},
|
||||
"sync": {},
|
||||
"system": {},
|
||||
"tags": {},
|
||||
"trailers": {},
|
||||
"user_usage_stats": {},
|
||||
"users": {},
|
||||
"videocodecs": {},
|
||||
"videos": {},
|
||||
"years": {},
|
||||
}
|
||||
|
||||
func isEmbyRootSegment(segment string) bool {
|
||||
if _, ok := embyRootSegments[segment]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := embyStaticSegments[segment]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
|
||||
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
|
||||
// 例如 /System/Info/Public、/Users/Public、/Sessions。
|
||||
func IsEmbyPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
path = multipleSlashesRE.ReplaceAllString(path, "/")
|
||||
lower := strings.ToLower(path)
|
||||
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
|
||||
return true
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return false
|
||||
}
|
||||
return isEmbyRootSegment(strings.ToLower(segments[0]))
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestRecordProgressHandlerIgnoresStaleSessionSequence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/history", recordProgressHandler(svc))
|
||||
|
||||
post := func(body string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent {
|
||||
t.Fatalf("newer progress status = %d", code)
|
||||
}
|
||||
if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent {
|
||||
t.Fatalf("stale progress status = %d", code)
|
||||
}
|
||||
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PositionMs != 90_000 || got.Sequence != 2 {
|
||||
t.Fatalf("stale handler request overwrote newer state: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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())
|
||||
}
|
||||
}
|
||||
@@ -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 将媒体项标记为给定用户的收藏。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+11
-8
@@ -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),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user