mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd54d42db7 | |||
| 4595b7c5be | |||
| ee3927cdf1 | |||
| 4b34fc4616 | |||
| b7f1cd85f8 | |||
| be9d3a6d9e | |||
| b8e48a1f45 | |||
| 670dd90dbe | |||
| 9a68ef701f | |||
| 386fc358d1 | |||
| 2989649445 | |||
| 5ddca9a1f6 | |||
| e337d9932b | |||
| df654ea47b | |||
| 41fe75e136 | |||
| e65accf2cd | |||
| f54ef2228c | |||
| 3c2bfb14ca | |||
| 8f64f961ac | |||
| 6797394c5b | |||
| e71d9d61d1 | |||
| 1ff60d5641 | |||
| 3f361631d0 | |||
| cfe0c0cf28 | |||
| d6355d5582 | |||
| 0f06bdf929 | |||
| e840cbd2c9 | |||
| 2bb3610da7 | |||
| 9ca1b28cb4 | |||
| 9effb1422b | |||
| b855e00345 |
@@ -14,6 +14,7 @@ verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.codex/
|
||||
.tmp/
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
|
||||
@@ -66,6 +66,7 @@ config.yaml
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
|
||||
fontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(fontResp, fontReq)
|
||||
if fontResp.Code != http.StatusOK {
|
||||
t.Fatalf("font status = %d, want 200", fontResp.Code)
|
||||
}
|
||||
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
|
||||
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
|
||||
}
|
||||
if got := fontResp.Body.String(); got != "wOF2-test-font" {
|
||||
t.Fatalf("font body = %q, want wOF2-test-font", got)
|
||||
}
|
||||
|
||||
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||
missingFontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingFontResp, missingFontReq)
|
||||
if missingFontResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
|
||||
}
|
||||
if strings.Contains(missingFontResp.Body.String(), "index") {
|
||||
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
|
||||
}
|
||||
|
||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
||||
brandResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(brandResp, brandReq)
|
||||
|
||||
+16
-6
@@ -19,13 +19,16 @@ import (
|
||||
"github.com/truewhile/MeBox/web"
|
||||
)
|
||||
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
if !cfg.App.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
|
||||
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
|
||||
}))
|
||||
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
|
||||
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
|
||||
}
|
||||
@@ -59,6 +62,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
c.Next()
|
||||
})
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
fonts := r.Group("/fonts")
|
||||
fonts.Use(middleware.GzipStatic())
|
||||
fonts.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Next()
|
||||
})
|
||||
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
@@ -70,11 +80,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +45,12 @@ func setDefaults(v *viper.Viper) {
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
|
||||
@@ -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"`
|
||||
@@ -124,6 +126,11 @@ type CacheConfig struct {
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -14,7 +16,11 @@ import (
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
uid := currentUserID(c)
|
||||
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
|
||||
opts := service.DanmakuFetchOptions{UserID: uid}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -23,11 +29,36 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
||||
// size, area, enabled) so the player can initialize its control panel without
|
||||
// admin privileges.
|
||||
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
|
||||
// preferences so the player can initialize without admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req service.DanmakuSettingsPatch
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
|
||||
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func newDanmakuSettingsService(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "user-1"
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.MergeSources {
|
||||
t.Fatal("response should echo merge_sources=true")
|
||||
}
|
||||
// 落库校验:重新读取应为 true。
|
||||
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||
t.Fatal("merge preference was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c2)
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.MergeSources {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) {
|
||||
t.Fatal("response must never expose the application secret")
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
t.Fatalf("unexpected service config: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"source":"ftp://dm.example.com"}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.DanmakuSource != "" {
|
||||
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
router := newEmbyCompatTestRouter(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||
// 的 HLS 兜底路由接走返回空 404。
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("expected an empty array, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||
t.Fatalf("content type = %q, want application/json", ct)
|
||||
}
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||
}
|
||||
}
|
||||
@@ -29,27 +29,87 @@ var embyPlaceholderPNG = []byte{
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
if svc == nil || svc.Emby == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
// PersonImageURL handles TMDb/remote people first and falls back to regular
|
||||
// media/library artwork, so the official /Items/{personId}/Images route
|
||||
// works for synthetic person IDs as well as normal item IDs.
|
||||
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
|
||||
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if svc == nil || svc.Emby == nil || id == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||
if infos == nil {
|
||||
infos = []map[string]any{}
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
}
|
||||
|
||||
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := strings.TrimSpace(c.Param("userId"))
|
||||
raw := ""
|
||||
if svc != nil && svc.Emby != nil && uid != "" {
|
||||
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||
}
|
||||
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||
embyMissingAvatar(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||
embyMissingAvatar(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||
func embyMissingAvatar(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||
progressReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
|
||||
nil,
|
||||
)
|
||||
wProgress := httptest.NewRecorder()
|
||||
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
if hist.PositionMs != 30000 {
|
||||
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
||||
}
|
||||
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
|
||||
t.Fatalf("unexpected playback session metadata: %#v", hist)
|
||||
}
|
||||
|
||||
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||
resumableReq := httptest.NewRequest(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/emby", want: true},
|
||||
{path: "/emby/System/Info/Public", want: true},
|
||||
{path: "//emby//Items//1", want: true},
|
||||
{path: "/System/Info/Public", want: true},
|
||||
{path: "/Search/Hints", want: true},
|
||||
{path: "/Playback/BitrateTest", want: true},
|
||||
{path: "/api/unknown", want: false},
|
||||
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
|
||||
{path: "/random/path", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsEmbyPath(tt.path); got != tt.want {
|
||||
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
|
||||
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
|
||||
if !changed || got != "/emby/api/stream/123" {
|
||||
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
var embyRootSegments = map[string]struct{}{
|
||||
"albums": {},
|
||||
"artists": {},
|
||||
"audio": {},
|
||||
"audiocodecs": {},
|
||||
"auth": {},
|
||||
"branding": {},
|
||||
"channels": {},
|
||||
"collections": {},
|
||||
"connect": {},
|
||||
"containers": {},
|
||||
"devices": {},
|
||||
"displaypreferences": {},
|
||||
"dlna": {},
|
||||
"encoding": {},
|
||||
"environment": {},
|
||||
"gamegenres": {},
|
||||
"games": {},
|
||||
"genres": {},
|
||||
"images": {},
|
||||
"items": {},
|
||||
"libraries": {},
|
||||
"library": {},
|
||||
"livestreams": {},
|
||||
"livetv": {},
|
||||
"localization": {},
|
||||
"movies": {},
|
||||
"musicgenres": {},
|
||||
"news": {},
|
||||
"notification": {},
|
||||
"notifications": {},
|
||||
"officialratings": {},
|
||||
"packages": {},
|
||||
"persons": {},
|
||||
"playback": {},
|
||||
"playlists": {},
|
||||
"plugins": {},
|
||||
"providers": {},
|
||||
"reports": {},
|
||||
"scheduledtasks": {},
|
||||
"search": {},
|
||||
"sessions": {},
|
||||
"shows": {},
|
||||
"songs": {},
|
||||
"studios": {},
|
||||
"subtitlecodecs": {},
|
||||
"sync": {},
|
||||
"system": {},
|
||||
"tags": {},
|
||||
"trailers": {},
|
||||
"user_usage_stats": {},
|
||||
"users": {},
|
||||
"videocodecs": {},
|
||||
"videos": {},
|
||||
"years": {},
|
||||
}
|
||||
|
||||
func isEmbyRootSegment(segment string) bool {
|
||||
if _, ok := embyRootSegments[segment]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := embyStaticSegments[segment]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
|
||||
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
|
||||
// 例如 /System/Info/Public、/Users/Public、/Sessions。
|
||||
func IsEmbyPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
path = multipleSlashesRE.ReplaceAllString(path, "/")
|
||||
lower := strings.ToLower(path)
|
||||
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
|
||||
return true
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return false
|
||||
}
|
||||
return isEmbyRootSegment(strings.ToLower(segments[0]))
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) {
|
||||
imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/t/p/w300/actor.jpg" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageData)
|
||||
}))
|
||||
defer imageServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Cache: config.CacheConfig{CacheDir: t.TempDir()},
|
||||
Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"},
|
||||
}
|
||||
tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil)
|
||||
emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb)
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
imageURL, _ := url.Parse(imageServer.URL)
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} })
|
||||
svc := &service.Container{Emby: emby, ImageProxy: proxy}
|
||||
|
||||
raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("resolved raw=%q err=%v", raw, err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "person~tmdb~101"},
|
||||
{Key: "type", Value: "Primary"},
|
||||
}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil)
|
||||
embyItemImageHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := len(w.Body.Bytes()); got != len(imageData) {
|
||||
t.Fatalf("image bytes=%d want %d", got, len(imageData))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -115,12 +115,31 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
//
|
||||
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||
// 返回 text/plain 的 404。
|
||||
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
@@ -202,6 +221,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
|
||||
@@ -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))
|
||||
|
||||
+143
-83
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -60,37 +61,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
limit := 10
|
||||
if withPreview {
|
||||
@@ -103,7 +104,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
out := make([]webLibraryPayload, 0, len(libs)+8)
|
||||
if withPreview {
|
||||
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
var previews []service.LibraryPreviewItem
|
||||
if c.Query("include_total") == "0" {
|
||||
previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
} else {
|
||||
previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -111,41 +117,55 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
||||
}
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
}
|
||||
}
|
||||
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
|
||||
// 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短
|
||||
// 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。
|
||||
includeRemote := true
|
||||
if len(targetSet) > 0 {
|
||||
includeRemote = false
|
||||
for id := range targetSet {
|
||||
if service.IsEmbyRemoteID(id) {
|
||||
includeRemote = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||
if svc.EmbyRemote != nil {
|
||||
if includeRemote && svc.EmbyRemote != nil {
|
||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||
for i, v := range allowedViews {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
@@ -170,13 +190,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
@@ -421,9 +441,14 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
sortSpec := parseMediaSort(c)
|
||||
var history map[string]time.Time
|
||||
if sortSpec.Field == "last_played" {
|
||||
history = mediaHistoryMap(c, svc)
|
||||
}
|
||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||
if !groupVersions {
|
||||
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
@@ -439,17 +464,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||
items := service.PaginateMediaItems(grouped, page, size)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"total": len(grouped),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
@@ -563,6 +590,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return remoteItems
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
@@ -666,25 +726,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func parseMediaSort(c *gin.Context) service.MediaSortSpec {
|
||||
if c == nil {
|
||||
return service.NormalizeMediaSort("", "")
|
||||
}
|
||||
return service.NormalizeMediaSort(c.Query("sort"), c.Query("order"))
|
||||
}
|
||||
|
||||
// mediaHistoryMap loads the current user's latest playback time per media ID.
|
||||
// It is only used for the explicit last_played sort so normal pagination keeps
|
||||
// its single-query path.
|
||||
func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time {
|
||||
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil {
|
||||
return nil
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := strings.TrimSpace(toString(uid))
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
var rows []struct {
|
||||
MediaID string `gorm:"column:media_id"`
|
||||
WatchedAt time.Time `gorm:"column:watched_at"`
|
||||
}
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.PlaybackHistory{}).
|
||||
Select("media_id, MAX(watched_at) AS watched_at").
|
||||
Where("user_id = ?", userID).
|
||||
Group("media_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]time.Time, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.MediaID] = row.WatchedAt
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||
TMDbID: 503314,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||
}
|
||||
seenSeries := false
|
||||
seenMovie := false
|
||||
for _, item := range res.Items {
|
||||
switch item.TMDbID {
|
||||
case 61709:
|
||||
seenSeries = true
|
||||
if item.EpisodeNum != 1 {
|
||||
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||
}
|
||||
case 503314:
|
||||
seenMovie = true
|
||||
}
|
||||
}
|
||||
if !seenSeries || !seenMovie {
|
||||
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -15,9 +15,12 @@ import (
|
||||
// ─── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
type progressReq struct {
|
||||
MediaID string `json:"media_id" binding:"required"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
MediaID string `json:"media_id" binding:"required"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionStartedAtMs int64 `json:"session_started_at_ms"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
}
|
||||
|
||||
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Playback.RecordProgress(
|
||||
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
|
||||
); err != nil {
|
||||
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
|
||||
UserID: toString(uid),
|
||||
MediaID: req.MediaID,
|
||||
PositionMs: req.PositionMs,
|
||||
DurationMs: req.DurationMs,
|
||||
SessionID: req.SessionID,
|
||||
SessionStartedAtMs: req.SessionStartedAtMs,
|
||||
Sequence: req.Sequence,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -76,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if c.Query("ids") == "1" {
|
||||
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ids": ids})
|
||||
return
|
||||
}
|
||||
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestRecordProgressHandlerIgnoresStaleSessionSequence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/history", recordProgressHandler(svc))
|
||||
|
||||
post := func(body string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent {
|
||||
t.Fatalf("newer progress status = %d", code)
|
||||
}
|
||||
if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent {
|
||||
t.Fatalf("stale progress status = %d", code)
|
||||
}
|
||||
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PositionMs != 90_000 || got.Sequence != 2 {
|
||||
t.Fatalf("stale handler request overwrote newer state: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
|
||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
sortSpec := parseMediaSort(c)
|
||||
var history map[string]time.Time
|
||||
if sortSpec.Field == "last_played" {
|
||||
history = mediaHistoryMap(c, svc)
|
||||
}
|
||||
// 远程剧集库:远程 Series 映射为系列卡片。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
|
||||
@@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||
if page < 1 {
|
||||
@@ -122,6 +129,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||
if page < 1 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
|
||||
@@ -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 将媒体项标记为给定用户的收藏。
|
||||
|
||||
@@ -28,6 +28,20 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,26 @@ func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, li
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ListAllByLibrariesFilteredNoCount loads every matching row without issuing a
|
||||
// COUNT query. Full-library consumers such as series grouping must scan the
|
||||
// whole library anyway, so calling it once is both cheaper and more consistent
|
||||
// than issuing paginated queries with repeated counts.
|
||||
func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) ([]model.Media, error) {
|
||||
items := make([]model.Media, 0)
|
||||
if len(libraryIDs) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{})
|
||||
if len(libraryIDs) == 1 {
|
||||
q = q.Where("library_id = ?", libraryIDs[0])
|
||||
} else {
|
||||
q = q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
|
||||
var items []model.Media
|
||||
var total int64
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,6 +11,18 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaUpsertItem carries a media row and optional sibling paths from an
|
||||
// earlier keep_ext naming mode. An alias is only used when the exact new path
|
||||
// does not exist in the database; in that case the existing row is migrated to
|
||||
// the new path so scraped metadata survives renames such as:
|
||||
//
|
||||
// foo.strm -> foo.mkv.strm
|
||||
// foo.mkv.strm -> foo.strm
|
||||
type MediaUpsertItem struct {
|
||||
Media *model.Media
|
||||
AliasPaths []string
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a media row keyed by Path (unique index).
|
||||
//
|
||||
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
|
||||
@@ -22,20 +35,14 @@ import (
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
var indexIDs []string
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
id, uerr := r.upsertWithDB(ctx, r.db, m)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
indexIDs = append(indexIDs[:0], id)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.indexByIDBestEffort(ctx, indexIDs)
|
||||
return nil
|
||||
return r.UpsertWithAliases(ctx, m, nil)
|
||||
}
|
||||
|
||||
// UpsertWithAliases is Upsert with explicit, filesystem-verified STRM sibling
|
||||
// aliases. It preserves the old row's ID, CreatedAt and scraped metadata while
|
||||
// moving it to the current path.
|
||||
func (r *MediaRepository) UpsertWithAliases(ctx context.Context, m *model.Media, aliasPaths []string) error {
|
||||
return r.UpsertBatchWithAliases(ctx, []MediaUpsertItem{{Media: m, AliasPaths: aliasPaths}})
|
||||
}
|
||||
|
||||
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
|
||||
@@ -46,6 +53,17 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
|
||||
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
|
||||
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
|
||||
mapped := make([]MediaUpsertItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
mapped = append(mapped, MediaUpsertItem{Media: item})
|
||||
}
|
||||
}
|
||||
return r.UpsertBatchWithAliases(ctx, mapped)
|
||||
}
|
||||
|
||||
// UpsertBatchWithAliases runs alias-aware upserts in one transaction.
|
||||
func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []MediaUpsertItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -53,11 +71,11 @@ func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media)
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
indexIDs = indexIDs[:0]
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, m := range items {
|
||||
if m == nil {
|
||||
for _, item := range items {
|
||||
if item.Media == nil {
|
||||
continue
|
||||
}
|
||||
id, err := r.upsertWithDB(ctx, tx, m)
|
||||
id, err := r.upsertWithDB(ctx, tx, item.Media, item.AliasPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,9 +105,9 @@ func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string)
|
||||
}
|
||||
}
|
||||
|
||||
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
|
||||
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
|
||||
// upsertWithDB 落库(新建、更新或从旧路径迁移),返回需要重建索引的媒体 ID。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (string, error) {
|
||||
existing, created, adopted, err := r.findOrCreateMediaByPath(ctx, db, m, aliasPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -98,6 +116,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
}
|
||||
|
||||
updates := mediaUpsertUpdates(existing, *m)
|
||||
if adopted {
|
||||
updates["path"] = m.Path
|
||||
if existing.DeletedAt.Valid {
|
||||
updates["deleted_at"] = nil
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
*m = existing
|
||||
return "", nil
|
||||
@@ -107,31 +131,67 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
return "", err
|
||||
}
|
||||
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
|
||||
if adopted {
|
||||
existing.Path = m.Path
|
||||
existing.DeletedAt = gorm.DeletedAt{}
|
||||
}
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if alias, aliasErr := r.findMediaByAlias(ctx, db, m.Path, aliasPaths); aliasErr == nil {
|
||||
return alias, false, true, nil
|
||||
} else if !errors.Is(aliasErr, gorm.ErrRecordNotFound) {
|
||||
return model.Media{}, false, false, aliasErr
|
||||
}
|
||||
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
|
||||
return *m, true, nil
|
||||
return *m, true, false, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, createErr
|
||||
return model.Media{}, false, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return model.Media{}, false, err
|
||||
return model.Media{}, false, false, err
|
||||
}
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, currentPath string, aliasPaths []string) (model.Media, error) {
|
||||
aliases := make([]string, 0, len(aliasPaths))
|
||||
seen := make(map[string]struct{}, len(aliasPaths))
|
||||
for _, alias := range aliasPaths {
|
||||
alias = filepath.Clean(strings.TrimSpace(alias))
|
||||
if alias == "" || alias == "." || alias == filepath.Clean(currentPath) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[alias]; ok {
|
||||
continue
|
||||
}
|
||||
seen[alias] = struct{}{}
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return model.Media{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().
|
||||
Where("path IN ?", aliases).
|
||||
Order("CASE WHEN scrape_status = 'matched' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN COALESCE(poster_url, '') <> '' OR COALESCE(overview, '') <> '' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC").
|
||||
First(&existing).Error
|
||||
return existing, err
|
||||
}
|
||||
|
||||
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// GetPeople resolves adult cast/crew on demand from MetaTube. The provider and
|
||||
// remote movie ID are persisted in the existing external-ID columns; the
|
||||
// returned people are intentionally not written to the database.
|
||||
func (p *AdultProvider) GetPeople(ctx context.Context, media *model.Media) []map[string]any {
|
||||
if p == nil || media == nil || !media.NSFW {
|
||||
return nil
|
||||
}
|
||||
provider := strings.TrimSpace(media.TheTVDBID)
|
||||
movieID := strings.TrimSpace(media.DoubanID)
|
||||
if provider == "" || movieID == "" {
|
||||
return nil
|
||||
}
|
||||
match, err := p.GetMetaTubeCandidate(ctx, provider, movieID)
|
||||
if err != nil || match == nil {
|
||||
return nil
|
||||
}
|
||||
return match.People
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDanmakuEpisodeNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "1"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "18"},
|
||||
{"第11话", "11"},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "29"},
|
||||
{"第 202 集", "202"},
|
||||
{"第1.5话 特别篇", "1.5"},
|
||||
{"正片", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeNumber(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeNumber(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDanmakuEpisodeSubtitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "裏切りの大空"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "自己相似的两性同体-Fractal Androgynous-"},
|
||||
{"第11话", ""},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "那我们走吧"},
|
||||
{"正片", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeSubtitle(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeSubtitle(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作
|
||||
// 或缺少集标题的平台源抢走。
|
||||
func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"},
|
||||
}},
|
||||
{AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("matched %d anime, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 {
|
||||
t.Fatalf("picked wrong source: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
|
||||
// 且顺序不可靠。必须靠副标题选中正确的那一集。
|
||||
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
|
||||
// 实测样本:官方 match「命运石之门 第18话 自己相似的两性同体-…」,
|
||||
// 配置源首条却是《命运石之门 0》的同一集号。
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11072, EpisodeTitle: "【dandan&animeko】 第18话 并进对称的牵牛星-Translational"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11095, EpisodeTitle: "【dandan&animeko】 第18话 自己相似的两性同体-Fractal Androgynous-"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "18", "第18话 自己相似的两性同体-Fractal Androgynous-")
|
||||
if !ok {
|
||||
t.Fatal("expected a confident match")
|
||||
}
|
||||
if got != 11095 {
|
||||
t.Fatalf("picked episodeId %d, want 11095 (first candidate is a different season)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标带副标题但没有任何候选的副标题对得上时,必须放弃而不是退回第一条,
|
||||
// 否则会给用户播放另一部番的弹幕。
|
||||
func TestPickDanmakuEpisodeIDRejectsWhenSubtitleNotFound(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番 第一季", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 111, EpisodeTitle: "第3话 完全不同的标题"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "3", "第3话 期望的标题"); ok {
|
||||
t.Fatalf("expected no match, got episodeId %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标没有副标题(如「第11话」)时,退而要求集数一致;集数对不上同样放弃。
|
||||
func TestPickDanmakuEpisodeIDNumberOnlyFallback(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "86 第二季", Episodes: []DanmakuEpisode{
|
||||
// 该源对第二季采用绝对集号,第 11 集记作第 22 话。
|
||||
{EpisodeID: 11113, EpisodeTitle: "第22话 辛"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "11", "第11话"); ok {
|
||||
t.Fatalf("episode number mismatch must be rejected, got episodeId %d", got)
|
||||
}
|
||||
|
||||
same := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 222, EpisodeTitle: "第11话"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(same, "11", "第11话")
|
||||
if !ok || got != 222 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (222,true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDanmakuEpisodeIDSkipsInvalidIDs(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 0, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: -5, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: 333, EpisodeTitle: "第1话 目标"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "1", "第1话 目标")
|
||||
if !ok || got != 333 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (333,true)", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -105,27 +105,359 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
|
||||
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
|
||||
}
|
||||
|
||||
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
|
||||
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
|
||||
// 第 1 层拉弹幕:官方 match 给出的 episodeId 属于官方 ID 空间,不能直接拿去
|
||||
// 请求第三方源(真实源上只会 404)。必须用官方给到的剧名+集数在配置源里重定位
|
||||
// 到配置源自己的 episodeId,再用它拉弹幕。
|
||||
func TestDanmakuFetchHashMatchRemapsEpisodeIDToConfiguredSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
|
||||
nil)
|
||||
// 配置源使用自己的 ID 空间:官方是 90001,配置源是 25484。
|
||||
// 副标题一致,用于跨源确认是同一集。
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕A</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mC", "", "")
|
||||
require.NoError(t, err)
|
||||
// 配置源优先:弹幕来自自定义源而非官方。
|
||||
// 弹幕取自配置源,且用的是重定位后的 ID。
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.NotContains(t, res.Raw, "弹幕B官方")
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
}
|
||||
|
||||
// 配置源能定位到该集,但返回的是空弹幕库(count=0)时必须回官方兜底:
|
||||
// 第三方目录里有条目不代表真的收录了弹幕。
|
||||
func TestDanmakuFetchHashMatchFallsBackWhenConfiguredLibraryIsEmpty(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// 该集在配置源上存在,但弹幕为空。
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":0,"comments":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方兜底弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mEmpty", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mEmpty", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "官方兜底弹幕")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
// 同一集在配置源里命中多个来源时:自动加载第一条,其余作为可切换来源返回,
|
||||
// 让用户能在面板里直接切换,而不必重新搜索。
|
||||
func TestDanmakuFetchHashMatchReturnsAlternatives(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "多来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一个副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个来源都指向同一集(副标题与集数一致),模拟 LogVar 聚合多站。
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"多来源动画 from dandan","episodes":[{"episodeId":101,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"多来源动画 from bilibili","episodes":[{"episodeId":102,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":3,"animeTitle":"多来源动画 from qq","episodes":[{"episodeId":103,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/101", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">首选来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":9,"animeTitle":"多来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mAlt", "多来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mAlt", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 自动加载第一个来源(沿用既有取值逻辑)。
|
||||
require.Contains(t, res.Raw, "首选来源弹幕")
|
||||
require.Equal(t, int64(101), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
|
||||
// 三个来源全部作为可切换列表返回。
|
||||
require.Len(t, res.Alternatives, 3)
|
||||
var ids []int64
|
||||
for _, a := range res.Alternatives {
|
||||
for _, e := range a.Episodes {
|
||||
ids = append(ids, e.EpisodeID)
|
||||
}
|
||||
}
|
||||
require.Equal(t, []int64{101, 102, 103}, ids)
|
||||
|
||||
// Candidates 的语义必须保持不变(这里不是"必须选择"),否则前端会停止自动加载。
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// 只有一个来源时不应产生 alternatives,避免面板出现无意义的单条列表。
|
||||
func TestDanmakuFetchHashMatchNoAlternativesForSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "单来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "唯一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1,"animeTitle":"单来源动画","episodes":[{"episodeId":201,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/201", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">唯一来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90002,"animeId":9,"animeTitle":"单来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mOne", "单来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mOne", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "唯一来源弹幕")
|
||||
require.Empty(t, res.Alternatives)
|
||||
}
|
||||
|
||||
// 开启合并后:同一集的多个来源被合并,重复弹幕(时间+内容相同)只保留一条。
|
||||
func TestDanmakuFetchMergeSourcesCombinesAndDeduplicates(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":301,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":302,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// A 与 B 各有一条重复弹幕(1.0 秒「重复弹幕」)和各自独有的一条。
|
||||
cfgMux.HandleFunc("/api/v2/comment/301", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u1","m":"重复弹幕"},`+
|
||||
`{"p":"2.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/302", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u2","m":"重复弹幕"},`+
|
||||
`{"p":"3.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90003,"animeId":9,"animeTitle":"合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mMerge", "合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.FetchWithOptions(ctx, "mMerge", "", "", DanmakuFetchOptions{MergeSources: true})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, res.MergedSources, "expected both sources to be merged")
|
||||
|
||||
merged := parseDanmakuComments(res.Raw)
|
||||
require.Len(t, merged, 3, "duplicate comment must collapse: got %#v", merged)
|
||||
require.Equal(t, "重复弹幕", merged[0].Text)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "只在A", merged[1].Text)
|
||||
require.Equal(t, "只在B", merged[2].Text)
|
||||
// 合并结果用 JSON 输出,前端据此选择解析分支。
|
||||
require.Equal(t, "json", res.SourceType)
|
||||
}
|
||||
|
||||
// 未开启合并时,行为与原来一致:只加载自动选中的那一个来源。
|
||||
func TestDanmakuFetchWithoutMergeLoadsSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "不合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":401,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":402,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/401", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/402", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90004,"animeId":9,"animeTitle":"不合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMerge", "不合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMerge", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, res.MergedSources)
|
||||
require.Equal(t, int64(401), res.EpisodeID)
|
||||
// 只应有自动选中来源的弹幕。
|
||||
require.Contains(t, res.Raw, "只在A")
|
||||
require.NotContains(t, res.Raw, "只在B")
|
||||
// 两个来源仍然作为可切换项返回(合并开关不影响候选列表)。
|
||||
require.Len(t, res.Alternatives, 2)
|
||||
}
|
||||
|
||||
// 合并偏好按用户持久化。
|
||||
func TestDanmakuMergeSourcesPreferencePersistsPerUser(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA := model.User{Username: "merge-user-a", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userA.ID = "user-a"
|
||||
userB := model.User{Username: "merge-user-b", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userB.ID = "user-b"
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userA))
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userB))
|
||||
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
require.NoError(t, svc.SetMergeSources(ctx, "user-a", true))
|
||||
require.True(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
// 另一个用户不受影响。
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-b"))
|
||||
|
||||
// 重新读取确认已落库,且 ConfigForUser 会带出该偏好。
|
||||
cfg := svc.ConfigForUser(ctx, "user-a")
|
||||
require.True(t, cfg.MergeSources)
|
||||
require.False(t, svc.ConfigForUser(ctx, "user-b").MergeSources)
|
||||
}
|
||||
|
||||
// 配置源搜不到对应剧集时必须回退官方:用官方自身的 episodeId 请求官方,
|
||||
// 而不是拿官方 ID 去撞配置源。
|
||||
func TestDanmakuFetchHashMatchFallsBackToOfficialWhenConfiguredHasNoMatch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "冷门动画.第01话.mkv")
|
||||
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"冷门动画","episodeTitle":"第1话 无人知晓"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕来自官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMatch", "冷门动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMatch", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕来自官方")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
@@ -159,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕官方兜底")
|
||||
}
|
||||
|
||||
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
|
||||
// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配;
|
||||
// 应继续走第 2 层按文件名+集数搜索。
|
||||
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[]}`,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
@@ -178,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
// 第 2 层命中:搜索请求按文件名进行。
|
||||
require.Contains(t, cfgSrv.lastSearch, "anime=")
|
||||
require.Equal(t, "filename", res.MatchMode)
|
||||
require.Equal(t, "测试动画", res.AnimeTitle)
|
||||
// 第 2 层命中:搜索请求按文件名进行,而不是误用官方第一条模糊候选。
|
||||
query, err := url.ParseQuery(cfgSrv.lastSearch)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, query.Get("anime"), "测试动画.第01话")
|
||||
require.NotContains(t, query.Get("anime"), "91天")
|
||||
}
|
||||
|
||||
// hash 未精确命中时,若本地已有刮削的剧名、年份、集数和集标题,应先用这些
|
||||
// 元数据锁定正确来源,而不是直接进入大量候选的手工选择。
|
||||
func TestDanmakuFetchHashMissUsesScrapedMetadata(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "local-release-name.mkv")
|
||||
cfgSrv := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"命运石之门 0(2018)【TV动画】from dandan&animeko","episodes":[{"episodeId":120140001,"episodeTitle":"【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}]},`+
|
||||
`{"animeId":2,"animeTitle":"命运石之门(2011)【TV动画】from dandan&animeko","episodes":[{"episodeId":25484,"episodeTitle":"【dandan&animeko】 第1话 始与终的序章-Turning Point-"}]},`+
|
||||
`{"animeId":3,"animeTitle":"命运石之门(2011)【动漫】from 360","episodes":[{"episodeId":120140002,"episodeTitle":"【qq】 第1集"}]}`+
|
||||
`]}`)
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
media := model.Media{
|
||||
Title: "命运石之门",
|
||||
Year: 2011,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Path: videoPath,
|
||||
SizeBytes: 32000,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
media.ID = "mScrapedMeta"
|
||||
require.NoError(t, svc.repo.DB.Create(&media).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, media.ID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, "metadata", res.MatchMode)
|
||||
require.Equal(t, "命运石之门(2011)【TV动画】from dandan&animeko", res.AnimeTitle)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||
@@ -446,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
|
||||
// 即使刮削元数据完整,也应先走准确率最高的 hash 层。
|
||||
func TestDanmakuFetchPrefersHashOverCompleteMetadata(t *testing.T) {
|
||||
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
var seen string
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"官方测试动画","episodeTitle":"第1话"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">hash命中</d></i>`,
|
||||
&seen)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
source := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL()))
|
||||
|
||||
m := model.Media{
|
||||
Title: "测试动画",
|
||||
Path: videoPath,
|
||||
SizeBytes: 123,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Year: 2011,
|
||||
}
|
||||
m.ID = "hash-first"
|
||||
require.NoError(t, svc.repo.DB.Create(&m).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, "hash-first", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
require.NotEmpty(t, res.Raw)
|
||||
require.Contains(t, seen, wantHash)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 弹幕合并:同一集在聚合源(如 LogVar)里常有多个库,开启合并后把这些库的
|
||||
// 弹幕一起去重后展示,而不是只显示其中一个。
|
||||
const (
|
||||
// danmakuMergeMaxSources 限制一次合并涉及的来源数量,避免把一次播放
|
||||
// 变成几十个上游请求。
|
||||
danmakuMergeMaxSources = 10
|
||||
// danmakuMergeConcurrency 限制并发抓取数,降低触发上游限流(429)的概率。
|
||||
danmakuMergeConcurrency = 4
|
||||
// danmakuMergeTimeToleranceSec 是判定「同一时间点」的容差。同一条弹幕在
|
||||
// 不同源之间可能因精度处理差上零点几秒,用容差比对;文本仍要求完全一致,
|
||||
// 因此不会把内容不同的弹幕误合。
|
||||
danmakuMergeTimeToleranceSec = 0.5
|
||||
)
|
||||
|
||||
// danmakuComment 是合并用的归一化弹幕。
|
||||
type danmakuComment struct {
|
||||
TimeSec float64
|
||||
Mode int
|
||||
Color int
|
||||
Text string
|
||||
}
|
||||
|
||||
// parseDanmakuComments 把上游载荷解析成归一化弹幕列表,兼容 dandanplay
|
||||
// JSON({comments:[{p,m}]})与 Bilibili XML(<d p="...">text</d>)两种格式。
|
||||
// 无法识别的载荷返回空列表,调用方据此跳过该来源。
|
||||
func parseDanmakuComments(raw string) []danmakuComment {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
if comments := parseDanmakuCommentsJSON(trimmed); len(comments) > 0 {
|
||||
return comments
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return parseDanmakuCommentsXML(trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsJSON(raw string) []danmakuComment {
|
||||
// 兼容 {comments:[...]} 与裸数组两种形态。
|
||||
var payload struct {
|
||||
Comments []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
// 少数自建源直接给结构化字段。
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
var bare []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
}
|
||||
if err2 := json.Unmarshal([]byte(raw), &bare); err2 != nil {
|
||||
return nil
|
||||
}
|
||||
payload.Comments = bare
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(payload.Comments))
|
||||
for _, item := range payload.Comments {
|
||||
comment, ok := buildDanmakuComment(item.P, item.M, item.Time, item.Text, item.Mode, item.Color)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsXML(raw string) []danmakuComment {
|
||||
var doc struct {
|
||||
Items []struct {
|
||||
P string `xml:"p,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
} `xml:"d"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(doc.Items))
|
||||
for _, item := range doc.Items {
|
||||
comment, ok := buildDanmakuComment(item.P, item.Text, nil, "", nil, nil)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDanmakuComment 从 p 串或结构化字段构造一条弹幕。p 串格式为
|
||||
// "time,mode,color,user"(dandanplay 四段式)。
|
||||
func buildDanmakuComment(p, text string, timeSec *float64, plainText string, mode, color *int) (danmakuComment, bool) {
|
||||
body := strings.TrimSpace(text)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(plainText)
|
||||
}
|
||||
if body == "" {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
comment := danmakuComment{Text: body, Mode: 1}
|
||||
if timeSec != nil {
|
||||
comment.TimeSec = *timeSec
|
||||
}
|
||||
if mode != nil && *mode > 0 {
|
||||
comment.Mode = *mode
|
||||
}
|
||||
if color != nil {
|
||||
comment.Color = *color
|
||||
}
|
||||
if fields := strings.Split(p, ","); len(fields) >= 1 {
|
||||
if t, err := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64); err == nil {
|
||||
comment.TimeSec = t
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
if m, err := strconv.Atoi(strings.TrimSpace(fields[1])); err == nil && m > 0 {
|
||||
comment.Mode = m
|
||||
}
|
||||
}
|
||||
// 颜色所在位置取决于格式,用段数区分(与前端 parseBilibiliXml 的判定
|
||||
// 一致):Bilibili 的 p 是 "time,mode,fontSize,color,..."(>=5 段,
|
||||
// 颜色在第 4 段);dandanplay 的 p 是 "time,mode,color,userId"(4 段,
|
||||
// 颜色在第 3 段)。不区分会把字号当成颜色。
|
||||
colorIndex := 2
|
||||
if len(fields) >= 5 {
|
||||
colorIndex = 3
|
||||
}
|
||||
if len(fields) > colorIndex {
|
||||
if c, err := strconv.Atoi(strings.TrimSpace(fields[colorIndex])); err == nil {
|
||||
comment.Color = c
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsNaN(comment.TimeSec) || math.IsInf(comment.TimeSec, 0) || comment.TimeSec < 0 {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
// 无颜色信息时用白色,与前端默认一致。
|
||||
if comment.Color <= 0 {
|
||||
comment.Color = 16777215
|
||||
}
|
||||
return comment, true
|
||||
}
|
||||
|
||||
// mergeDanmakuComments 合并多组弹幕并按「时间 + 内容」去重。
|
||||
//
|
||||
// 判定重复的条件:文本完全一致,且时间差在 danmakuMergeTimeToleranceSec 以内。
|
||||
// 之所以同时要求文本一致,是因为容差本身不足以区分内容;之所以需要容差,
|
||||
// 是因为同一条弹幕在不同来源间可能因精度处理差上零点几秒。
|
||||
func mergeDanmakuComments(sets [][]danmakuComment) []danmakuComment {
|
||||
merged := make([]danmakuComment, 0, 512)
|
||||
// keptTimes[文本] = 已保留的该文本时间列表,用于就近比对。
|
||||
keptTimes := make(map[string][]float64)
|
||||
for _, set := range sets {
|
||||
for _, comment := range set {
|
||||
times := keptTimes[comment.Text]
|
||||
duplicate := false
|
||||
for _, kept := range times {
|
||||
if math.Abs(kept-comment.TimeSec) <= danmakuMergeTimeToleranceSec {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
continue
|
||||
}
|
||||
keptTimes[comment.Text] = append(times, comment.TimeSec)
|
||||
merged = append(merged, comment)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool { return merged[i].TimeSec < merged[j].TimeSec })
|
||||
return merged
|
||||
}
|
||||
|
||||
// encodeDanmakuComments 把合并结果编码成 dandanplay JSON,前端 parseDanmaku
|
||||
// 已支持该格式({comments:[{p,m}]})。
|
||||
func encodeDanmakuComments(comments []danmakuComment) string {
|
||||
type item struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
}
|
||||
payload := struct {
|
||||
Count int `json:"count"`
|
||||
Comments []item `json:"comments"`
|
||||
}{Count: len(comments), Comments: make([]item, 0, len(comments))}
|
||||
for i, comment := range comments {
|
||||
payload.Comments = append(payload.Comments, item{
|
||||
Cid: i + 1,
|
||||
P: fmt.Sprintf("%.2f,%d,%d,merged", comment.TimeSec, comment.Mode, comment.Color),
|
||||
M: comment.Text,
|
||||
T: int(comment.TimeSec),
|
||||
})
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDanmakuCommentsSupportsDandanplayJson(t *testing.T) {
|
||||
raw := `{"count":2,"comments":[` +
|
||||
`{"cid":1,"p":"1.50,1,16777215,userA","m":"第一条"},` +
|
||||
`{"cid":2,"p":"2.00,5,16711680,userB","m":"顶部红字"}]}`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 2)
|
||||
require.Equal(t, 1.5, comments[0].TimeSec)
|
||||
require.Equal(t, 1, comments[0].Mode)
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
require.Equal(t, "第一条", comments[0].Text)
|
||||
// 第 5 种模式是顶部弹幕,颜色 16711680 = 0xFF0000。
|
||||
require.Equal(t, 5, comments[1].Mode)
|
||||
require.Equal(t, 16711680, comments[1].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsSupportsBilibiliXml(t *testing.T) {
|
||||
raw := `<?xml version="1.0" encoding="UTF-8"?><i>` +
|
||||
`<d p="3.25,1,25,16777215,1700000000,0,abc,def">来自 XML</d>` +
|
||||
`</i>`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 1)
|
||||
require.Equal(t, 3.25, comments[0].TimeSec)
|
||||
require.Equal(t, "来自 XML", comments[0].Text)
|
||||
// Bilibili 的 5 段式里第 3 段是字号、第 4 段才是颜色,必须取第 4 段。
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsReturnsNilForUnsupportedPayload(t *testing.T) {
|
||||
require.Nil(t, parseDanmakuComments(""))
|
||||
require.Nil(t, parseDanmakuComments("not a danmaku payload"))
|
||||
}
|
||||
|
||||
// 同一时间 + 同一内容视为重复,只保留一条。
|
||||
func TestMergeDanmakuCommentsDeduplicatesByTimeAndText(t *testing.T) {
|
||||
setA := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"},
|
||||
{TimeSec: 5.0, Mode: 1, Color: 16777215, Text: "只有A有"},
|
||||
}
|
||||
setB := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"}, // 与 A 完全重复
|
||||
{TimeSec: 5.2, Mode: 1, Color: 16777215, Text: "只有B有"},
|
||||
}
|
||||
merged := mergeDanmakuComments([][]danmakuComment{setA, setB})
|
||||
require.Len(t, merged, 3)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "哈哈", merged[0].Text)
|
||||
require.Equal(t, "只有A有", merged[1].Text)
|
||||
require.Equal(t, "只有B有", merged[2].Text)
|
||||
}
|
||||
|
||||
// 时间差在容差内且文本一致时也判为重复(跨源可能有零点几秒的精度差)。
|
||||
func TestMergeDanmakuCommentsDeduplicatesWithinTolerance(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 10.0, Mode: 1, Text: "2333"}},
|
||||
{{TimeSec: 10.3, Mode: 1, Text: "2333"}},
|
||||
})
|
||||
require.Len(t, merged, 1)
|
||||
require.Equal(t, 10.0, merged[0].TimeSec)
|
||||
}
|
||||
|
||||
// 内容相同但时间相距较远时是两条独立弹幕,不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsSameTextAtDifferentTimes(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前方高能"}},
|
||||
{{TimeSec: 30.0, Mode: 1, Text: "前方高能"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
// 时间相同但内容不同也不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsDifferentTextAtSameTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "AAA"}},
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "BBB"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
func TestMergeDanmakuCommentsSortsByTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 9.0, Mode: 1, Text: "后"}},
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, 9.0, merged[1].TimeSec)
|
||||
}
|
||||
|
||||
// 编码结果必须能被前端的 dandanplay JSON 分支解析(p + m 两个字符串字段)。
|
||||
func TestEncodeDanmakuCommentsProducesDandanplayShape(t *testing.T) {
|
||||
encoded := encodeDanmakuComments([]danmakuComment{
|
||||
{TimeSec: 1.5, Mode: 5, Color: 16711680, Text: "顶部"},
|
||||
})
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Comments []struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(encoded), &payload))
|
||||
require.Equal(t, 1, payload.Count)
|
||||
require.Len(t, payload.Comments, 1)
|
||||
require.Equal(t, "1.50,5,16711680,merged", payload.Comments[0].P)
|
||||
require.Equal(t, "顶部", payload.Comments[0].M)
|
||||
|
||||
// 回环:编码后的载荷应能再次解析出一致的弹幕。
|
||||
roundTrip := parseDanmakuComments(encoded)
|
||||
require.Len(t, roundTrip, 1)
|
||||
require.Equal(t, 1.5, roundTrip[0].TimeSec)
|
||||
require.Equal(t, 5, roundTrip[0].Mode)
|
||||
require.Equal(t, 16711680, roundTrip[0].Color)
|
||||
require.Equal(t, "顶部", roundTrip[0].Text)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -22,7 +24,8 @@ func newDanmakuTestService(t *testing.T) *DanmakuService {
|
||||
// 独立临时文件库,避免测试间通过共享内存库串数据。
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
|
||||
// 需要 users 表:弹幕合并偏好按用户存储在 user 行上。
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}))
|
||||
repos := repository.New(db)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
@@ -57,6 +60,7 @@ func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName
|
||||
type danmakuSourceServer struct {
|
||||
server *httptest.Server
|
||||
lastSearch string // full query (anime=...&episode=...)
|
||||
lastHeaders http.Header
|
||||
searchResponse string // JSON body served for /api/v2/search/episodes
|
||||
}
|
||||
|
||||
@@ -73,6 +77,7 @@ func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danm
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
ds.lastSearch = r.URL.RawQuery
|
||||
ds.lastHeaders = r.Header.Clone()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, ds.searchResponse)
|
||||
})
|
||||
@@ -325,3 +330,117 @@ 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))
|
||||
}
|
||||
|
||||
func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := model.User{Username: "danmaku-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "danmaku-user-1"
|
||||
user.DanmakuEnabled = true
|
||||
user.DanmakuSource = srv.URL()
|
||||
user.DanmakuAppID = "user-app-id"
|
||||
user.DanmakuAppKey = "user-app-secret"
|
||||
user.DanmakuOpacity = 0.65
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
require.NotEmpty(t, srv.lastHeaders.Get("X-Signature"))
|
||||
|
||||
// Credential rotation must invalidate the cached fetch for the same user.
|
||||
require.NoError(t, svc.repo.User.UpdateFields(ctx, user.ID, map[string]any{
|
||||
"danmaku_app_id": "user-app-id-2",
|
||||
"danmaku_app_key": "user-app-secret-2",
|
||||
}))
|
||||
_, err = svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-app-id-2", srv.lastHeaders.Get("X-AppId"))
|
||||
}
|
||||
|
||||
@@ -32,17 +32,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
}
|
||||
return backdrop
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
// Latest JSON can outlive (or be served after) the in-memory artwork
|
||||
// map. Rebuild from the library instead of handing the client a 1x1
|
||||
// placeholder that it then caches as a successful image.
|
||||
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, id)
|
||||
if err == nil && m != nil {
|
||||
@@ -71,6 +68,107 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
season, ok, err := e.findSeasonGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
||||
}
|
||||
series, ok, err := e.findSeriesGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
}
|
||||
|
||||
// PersonImageURL resolves a person avatar from either a disguised remote ID
|
||||
// or a person display name captured from a remote item's People field.
|
||||
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) {
|
||||
idOrName = strings.TrimSpace(idOrName)
|
||||
if idOrName == "" || e == nil {
|
||||
return "", nil
|
||||
}
|
||||
if IsEmbyRemoteID(idOrName) {
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil {
|
||||
profilePath := tmdbProfilePathFromTag(tag)
|
||||
if profilePath == "" {
|
||||
profilePath = e.cachedPersonImage(idOrName)
|
||||
}
|
||||
if profilePath != "" {
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
if e.remote != nil {
|
||||
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
|
||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||
var imageInfoTypes = []string{"Primary", "Backdrop"}
|
||||
|
||||
// ImageInfos 返回条目的图片清单,对应 Emby 的 GET /Items/{Id}/Images。
|
||||
// 客户端用它在详情页决定要加载哪些图;缺失该接口会落到 404,部分客户端
|
||||
// 因此把条目当成"无图"而放弃渲染海报。
|
||||
func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]any {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, 2)
|
||||
seen := map[string]bool{}
|
||||
for _, imageType := range imageInfoTypes {
|
||||
raw, err := e.ImageURL(ctx, id, imageType)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
// 非 Backdrop 类型在缺图时会回退到主图,去重避免同一张图重复出现。
|
||||
if raw == "" || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
out = append(out, map[string]any{
|
||||
"ImageType": imageType,
|
||||
"ImageIndex": 0,
|
||||
"ImageTag": id,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserAvatarURL 返回用户头像的来源地址;用户未设置头像时返回空串。
|
||||
func (e *EmbyService) UserAvatarURL(ctx context.Context, userID string) string {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || e == nil || e.repo == nil || e.repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
user, err := e.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(user.AvatarURL)
|
||||
}
|
||||
|
||||
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
|
||||
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
|
||||
if e == nil || strings.TrimSpace(id) == "" {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
||||
@@ -61,6 +62,19 @@ type EmbyService struct {
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
|
||||
peopleMu sync.RWMutex
|
||||
peopleCache map[string]embyPeopleCacheEntry
|
||||
|
||||
// latestFlight collapses the homepage stampede: clients request Latest
|
||||
// for every library at once, and a shared expiry used to rebuild each
|
||||
// library in parallel.
|
||||
latestFlight singleflight.Group
|
||||
|
||||
tmdb *TMDbProvider
|
||||
adult *AdultProvider
|
||||
personImageMu sync.RWMutex
|
||||
personImages map[string]string
|
||||
}
|
||||
|
||||
// NewEmbyService is the constructor.
|
||||
@@ -76,6 +90,23 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
|
||||
return e
|
||||
}
|
||||
|
||||
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
|
||||
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.tmdb = tmdb
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// SetAdultProvider wires the on-demand adult-metadata provider used when a
|
||||
// detail request needs cast/crew not stored in the database.
|
||||
func (e *EmbyService) SetAdultProvider(adult *AdultProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.adult = adult
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
|
||||
if e != nil {
|
||||
e.cache = cache
|
||||
@@ -115,8 +146,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 +176,13 @@ type embyVisibilityCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
|
||||
// list refresh. TV clients commonly request the same posters/items repeatedly.
|
||||
type embyPeopleCacheEntry struct {
|
||||
people []map[string]any
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
|
||||
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
|
||||
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
|
||||
|
||||
@@ -3,12 +3,24 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
|
||||
var cached embyCountsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return map[string]any{
|
||||
"MovieCount": cached.MovieCount,
|
||||
"SeriesCount": int(cached.SeriesCount),
|
||||
"EpisodeCount": cached.EpisodeCount,
|
||||
"ItemCount": cached.ItemCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
return e.applyUserMediaVisibility(ctx, q, userID)
|
||||
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
|
||||
MovieCount: movieCount,
|
||||
SeriesCount: int64(seriesCount),
|
||||
EpisodeCount: episodeCount,
|
||||
ItemCount: itemCount,
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return map[string]any{
|
||||
"MovieCount": movieCount,
|
||||
"SeriesCount": seriesCount,
|
||||
|
||||
@@ -9,13 +9,22 @@ import (
|
||||
)
|
||||
|
||||
type embyItemsCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyLatestCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Items []map[string]any `json:"items"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyCountsCacheValue struct {
|
||||
MovieCount int64 `json:"movie_count"`
|
||||
SeriesCount int64 `json:"series_count"`
|
||||
EpisodeCount int64 `json:"episode_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
@@ -43,10 +52,25 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
return "media:emby:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
|
||||
const defaultEmbyLatestCacheTTLSeconds = 300
|
||||
|
||||
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
|
||||
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
|
||||
// 观察到 73 个并发),缓存一旦集中过期,这批请求会同时穿透并各自重建
|
||||
// payload。延长后稳态下几乎全部命中缓存,冷启动频率也随之下降。
|
||||
func (e *EmbyService) embyLatestCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.EmbyLatestTTLSeconds < 1 {
|
||||
return defaultEmbyLatestCacheTTLSeconds
|
||||
}
|
||||
return e.cfg.Cache.EmbyLatestTTLSeconds
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
|
||||
return 90
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -62,14 +65,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seasonPayload(season), nil
|
||||
item := e.seasonPayload(season)
|
||||
if media := seriesPeopleMedia(season.Series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
@@ -80,7 +91,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -103,46 +118,90 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
}
|
||||
}
|
||||
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
|
||||
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
|
||||
return items, nil
|
||||
}
|
||||
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
|
||||
loadCtx := context.WithoutCancel(ctx)
|
||||
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
|
||||
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
|
||||
return embyLatestCacheValue{Items: items}, nil
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
e.rememberArtworkRefs(value.Artwork)
|
||||
return value, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached, _ := v.(embyLatestCacheValue)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return cached.Items, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
|
||||
if e == nil || e.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached.Items, nil
|
||||
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return nil, false
|
||||
}
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, true
|
||||
}
|
||||
return cached.Items, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return embyLatestCacheValue{Items: []map[string]any{}}, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
if err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
return embyLatestCacheValue{Items: out}, nil
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if parentID != "" {
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err == nil && e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
return out, err
|
||||
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
|
||||
}
|
||||
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
|
||||
}
|
||||
@@ -155,7 +214,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
if len(rows) > limit {
|
||||
@@ -163,15 +222,12 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
}
|
||||
out, err := e.payloadsForMedia(ctx, rows, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
return embyLatestCacheValue{Items: out}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
@@ -179,8 +235,9 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||
@@ -191,7 +248,25 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
for _, group := range groups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return items, nil
|
||||
return items, e.artworkRefsForSeriesGroups(groups), nil
|
||||
}
|
||||
|
||||
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
|
||||
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
|
||||
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
|
||||
// 仍走完整分组。上限 2000 行。
|
||||
func embyLatestSeriesRowLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
n := limit * 40
|
||||
if n < 200 {
|
||||
n = 200
|
||||
}
|
||||
if n > 2000 {
|
||||
n = 2000
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
@@ -255,7 +330,7 @@ func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[str
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0))
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0, false))
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
||||
@@ -378,7 +453,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
|
||||
// 足够准确。
|
||||
needed := p.StartIndex + p.Limit
|
||||
items := make([]map[string]any, 0, p.Limit)
|
||||
slots := make([]resumeSlot, 0, len(hist))
|
||||
localTotal, remoteTotal := 0, 0
|
||||
for _, h := range hist {
|
||||
if m, ok := byID[h.MediaID]; ok {
|
||||
@@ -386,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
if produced := len(items); produced < needed {
|
||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
||||
}
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||
continue
|
||||
}
|
||||
remoteTotal++
|
||||
if len(items) >= needed {
|
||||
continue
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
continue
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
continue
|
||||
}
|
||||
if p.ParentID != "" {
|
||||
parentID, _ := item["ParentId"].(string)
|
||||
seriesID, _ := item["SeriesId"].(string)
|
||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
|
||||
}
|
||||
for {
|
||||
filled := 0
|
||||
pending := make([]*resumeRemoteFetch, 0)
|
||||
for i := range slots {
|
||||
if slots[i].item != nil {
|
||||
filled++
|
||||
if filled >= needed {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
f := slots[i].fetch
|
||||
if f == nil || f.done {
|
||||
continue
|
||||
}
|
||||
if f.mount == nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
f.done = true
|
||||
continue
|
||||
}
|
||||
f.mount, f.acct, f.remoteID = mount, acct, remoteID
|
||||
}
|
||||
pending = append(pending, f)
|
||||
if filled+len(pending) >= needed {
|
||||
break
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
||||
items = append(items, item)
|
||||
if len(pending) == 0 {
|
||||
break
|
||||
}
|
||||
e.fillResumeRemotes(ctx, pending)
|
||||
for i := range slots {
|
||||
f := slots[i].fetch
|
||||
if f == nil || !pendingContains(pending, f) {
|
||||
continue
|
||||
}
|
||||
f.done = true
|
||||
item := f.item
|
||||
f.item = nil
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if p.ParentID != "" {
|
||||
parentID, _ := item["ParentId"].(string)
|
||||
seriesID, _ := item["SeriesId"].(string)
|
||||
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
|
||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
||||
slots[i].item = item
|
||||
}
|
||||
}
|
||||
items := make([]map[string]any, 0, p.Limit)
|
||||
for i := range slots {
|
||||
if slots[i].item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, slots[i].item)
|
||||
}
|
||||
|
||||
total := int64(localTotal + remoteTotal)
|
||||
@@ -426,17 +542,70 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
||||
type resumeSlot struct {
|
||||
item map[string]any
|
||||
fetch *resumeRemoteFetch
|
||||
}
|
||||
|
||||
type resumeRemoteFetch struct {
|
||||
hist model.PlaybackHistory
|
||||
mount *model.EmbyMount
|
||||
acct *model.StrmAccount
|
||||
remoteID string
|
||||
item map[string]any
|
||||
done bool
|
||||
}
|
||||
|
||||
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
|
||||
for _, row := range rows {
|
||||
if row == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
|
||||
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
|
||||
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
|
||||
if e == nil || e.remote == nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 4)
|
||||
for i := range rows {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
|
||||
if err != nil || item == nil {
|
||||
return
|
||||
}
|
||||
rows[i].item = item
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
|
||||
itemType := "Movie"
|
||||
name := m.Title
|
||||
parentID := m.LibraryID
|
||||
seriesID := m.SeriesID
|
||||
seriesName := ""
|
||||
seasonID := ""
|
||||
seasonNum := m.SeasonNum
|
||||
if e.mediaShouldBeEpisode(ctx, m) {
|
||||
itemType = "Episode"
|
||||
seriesID = e.seriesIDForMedia(ctx, m)
|
||||
seriesName = e.seriesNameForMedia(ctx, m)
|
||||
seasonNum = embySeasonNumForMedia(m)
|
||||
seasonID = e.seasonIDForMedia(ctx, m)
|
||||
parentID = seasonID
|
||||
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
||||
@@ -481,7 +650,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"MediaType": "Video",
|
||||
"IsFolder": false,
|
||||
"ProductionYear": m.Year,
|
||||
"ParentIndexNumber": m.SeasonNum,
|
||||
"ParentIndexNumber": seasonNum,
|
||||
"IndexNumber": m.EpisodeNum,
|
||||
"Overview": m.Overview,
|
||||
"RunTimeTicks": runTimeTicks,
|
||||
@@ -493,13 +662,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"Path": m.Path,
|
||||
"ParentId": parentID,
|
||||
"SeasonId": seasonID,
|
||||
"SeasonName": seasonName(m.SeasonNum),
|
||||
"SeasonName": seasonName(seasonNum),
|
||||
"SeriesId": seriesID,
|
||||
"SeriesName": seriesName,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"Genres": splitCSV(m.Genres),
|
||||
"People": e.resolveMediaPeople(ctx, m),
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -519,21 +688,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 +811,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 +860,89 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
return people
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
e.peopleMu.RLock()
|
||||
entry, ok := e.peopleCache[key]
|
||||
e.peopleMu.RUnlock()
|
||||
if !ok || now.After(entry.expiresAt) {
|
||||
if ok {
|
||||
e.peopleMu.Lock()
|
||||
delete(e.peopleCache, key)
|
||||
e.peopleMu.Unlock()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, len(entry.people))
|
||||
copy(out, entry.people)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
e.peopleMu.Lock()
|
||||
defer e.peopleMu.Unlock()
|
||||
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
|
||||
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
|
||||
}
|
||||
stored := make([]map[string]any, len(people))
|
||||
copy(stored, people)
|
||||
ttl := embyPeopleCacheTTL
|
||||
if len(stored) == 0 {
|
||||
ttl = embyPeopleEmptyCacheTTL
|
||||
}
|
||||
e.peopleCache[key] = embyPeopleCacheEntry{
|
||||
people: stored,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
|
||||
if e == nil || len(people) == 0 {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
defer e.personImageMu.Unlock()
|
||||
if e.personImages == nil || len(e.personImages) > 20000 {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
for _, person := range people {
|
||||
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
|
||||
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
|
||||
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
|
||||
e.personImages[id] = profilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedPersonImage(id string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
e.personImageMu.RLock()
|
||||
profilePath := e.personImages[strings.TrimSpace(id)]
|
||||
e.personImageMu.RUnlock()
|
||||
return profilePath
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
|
||||
profilePath = strings.TrimSpace(profilePath)
|
||||
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
if e.personImages == nil {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
e.personImages[strings.TrimSpace(id)] = profilePath
|
||||
e.personImageMu.Unlock()
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
orderIncludesDirection = false
|
||||
case "premieredate", "productionyear":
|
||||
order = mediaReleaseOrderSQL(desc)
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "dateplayed":
|
||||
order = "resume.watched_at"
|
||||
orderIncludesDirection = false
|
||||
@@ -181,7 +181,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if libraryID != "" {
|
||||
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
|
||||
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(pageGroups))
|
||||
for _, group := range pageGroups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
|
||||
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
|
||||
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
|
||||
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
|
||||
apply := func(q *gorm.DB) *gorm.DB {
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
if err := movieQ.Find(&movieRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
|
||||
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
|
||||
// 查询并把响应时间浪费在用户根本看不到的条目上。
|
||||
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
|
||||
|
||||
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
|
||||
type entry struct {
|
||||
sortAt time.Time
|
||||
payload map[string]any
|
||||
sortAt time.Time
|
||||
media *model.Media
|
||||
group *embySeriesGroup
|
||||
}
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
||||
for _, g := range seriesGroups {
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
|
||||
for i := range seriesGroups {
|
||||
group := &seriesGroups[i]
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
|
||||
}
|
||||
for _, item := range movieItems {
|
||||
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
|
||||
for i := range movieRows {
|
||||
media := &movieRows[i]
|
||||
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].sortAt.After(entries[j].sortAt)
|
||||
})
|
||||
total := len(entries)
|
||||
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
|
||||
pageMovies := make([]model.Media, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
items = append(items, en.payload)
|
||||
if en.media != nil {
|
||||
pageMovies = append(pageMovies, *en.media)
|
||||
}
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadByID := make(map[string]map[string]any, len(moviePayloads))
|
||||
for _, item := range moviePayloads {
|
||||
if id, ok := item["Id"].(string); ok {
|
||||
payloadByID[id] = item
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
pageGroups := make([]embySeriesGroup, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
switch {
|
||||
case en.group != nil:
|
||||
pageGroups = append(pageGroups, *en.group)
|
||||
items = append(items, e.seriesPayload(*en.group))
|
||||
case en.media != nil:
|
||||
if item := payloadByID[en.media.ID]; item != nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -55,3 +62,48 @@ func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
|
||||
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/movie/11/credits" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"cast": []map[string]any{
|
||||
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := newTestEmbyService(t)
|
||||
svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{
|
||||
TMDbAPIKey: "test-key",
|
||||
TMDbAPIProxy: server.URL,
|
||||
TMDbImageProxy: "https://image.example/t/p",
|
||||
}}, zap.NewNop(), nil))
|
||||
media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), media)
|
||||
peopleAgain := svc.resolveMediaPeople(t.Context(), media)
|
||||
if len(people) != 1 || len(peopleAgain) != 1 {
|
||||
t.Fatalf("people=%#v again=%#v", people, peopleAgain)
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("TMDb credits hits=%d want 1", hits.Load())
|
||||
}
|
||||
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
|
||||
t.Fatalf("unexpected person: %#v", people[0])
|
||||
}
|
||||
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw != "https://image.example/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("person image URL=%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
|
||||
return out, nil
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
}
|
||||
return map[string]any{
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
|
||||
http *http.Client
|
||||
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
||||
cache *RuntimeCacheService
|
||||
|
||||
personMu sync.RWMutex
|
||||
personImages map[string]embyRemotePersonImageRef
|
||||
}
|
||||
|
||||
type embyRemotePersonImageRef struct {
|
||||
accountID string
|
||||
remoteID string
|
||||
}
|
||||
|
||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||
@@ -797,16 +805,30 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
|
||||
|
||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
cacheKey := ""
|
||||
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
|
||||
cacheKey = r.remoteCacheKey("item", mount.ID, remoteID)
|
||||
var cached map[string]any
|
||||
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
|
||||
r.rememberRemotePeople(mount, cached)
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.rememberRemotePeople(mount, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
if cacheKey != "" && len(out) > 0 {
|
||||
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -959,6 +981,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
|
||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||
}
|
||||
|
||||
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
|
||||
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
|
||||
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
|
||||
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
|
||||
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
|
||||
return
|
||||
}
|
||||
people := remotePeopleMaps(payload["People"])
|
||||
if len(people) == 0 {
|
||||
return
|
||||
}
|
||||
r.personMu.Lock()
|
||||
defer r.personMu.Unlock()
|
||||
if r.personImages == nil || len(r.personImages) > 20000 {
|
||||
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
|
||||
}
|
||||
for _, person := range people {
|
||||
name := strings.TrimSpace(remoteItemString(person, "Name"))
|
||||
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
|
||||
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
|
||||
continue
|
||||
}
|
||||
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
|
||||
accountID: mount.AccountID,
|
||||
remoteID: remoteID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
|
||||
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
r.personMu.RLock()
|
||||
ref, ok := r.personImages[key]
|
||||
r.personMu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
acct := r.AccountByID(ctx, ref.accountID)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
func remotePeopleMaps(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if person, ok := item.(map[string]any); ok {
|
||||
out = append(out, person)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||
|
||||
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
|
||||
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
|
||||
if people, ok := m["People"]; ok {
|
||||
RewriteEmbyRemoteIDs(people, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
"Items": []any{
|
||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||
},
|
||||
"People": []any{
|
||||
map[string]any{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||
t.Fatalf("nested Id = %v", nested["Id"])
|
||||
}
|
||||
person := payload["People"].([]any)[0].(map[string]any)
|
||||
if person["Id"] != "embyremote~acct-1~person-1" {
|
||||
t.Fatalf("person Id = %v", person["Id"])
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
|
||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Id": "item-1",
|
||||
"Name": "测试电影",
|
||||
"People": []map[string]any{
|
||||
{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "user-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-people"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
|
||||
|
||||
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "People") {
|
||||
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
|
||||
}
|
||||
people, ok := out["People"].([]any)
|
||||
if !ok || len(people) != 1 {
|
||||
t.Fatalf("People = %#v, want one person", out["People"])
|
||||
}
|
||||
person := people[0].(map[string]any)
|
||||
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
|
||||
t.Fatalf("person Id = %v, want encoded remote id", got)
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
|
||||
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
|
||||
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
|
||||
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
|
||||
// 供调用方免二次请求读取 Type / SeriesId 等字段。
|
||||
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
|
||||
cacheKey := ""
|
||||
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
|
||||
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
|
||||
var cached struct {
|
||||
Media *model.Media `json:"media"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
|
||||
return cached.Media, cached.Raw, nil
|
||||
}
|
||||
}
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
|
||||
if cacheKey != "" {
|
||||
r.cache.SetJSON(ctx, cacheKey, struct {
|
||||
Media *model.Media `json:"media"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return &m, out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -143,8 +143,11 @@ func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id,
|
||||
}
|
||||
matched := make([]string, 0, 1)
|
||||
for _, cand := range candidates {
|
||||
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
|
||||
matched = append(matched, cand.SeriesID)
|
||||
for _, seasonNum := range embySeasonCandidates(cand.SeasonNum) {
|
||||
if seasonID(cand.SeriesID, seasonNum) == id {
|
||||
matched = append(matched, cand.SeriesID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, matchedSeries := range matched {
|
||||
@@ -257,10 +260,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
|
||||
bySeason := map[int]*embySeasonGroup{}
|
||||
order := []int{}
|
||||
for _, episode := range series.Episodes {
|
||||
seasonNum := episode.SeasonNum
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
}
|
||||
seasonNum := embySeasonNumForMedia(&episode)
|
||||
season, ok := bySeason[seasonNum]
|
||||
if !ok {
|
||||
season = &embySeasonGroup{
|
||||
@@ -276,7 +276,9 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
|
||||
}
|
||||
season.Episodes = append(season.Episodes, episode)
|
||||
}
|
||||
sort.Ints(order)
|
||||
sort.SliceStable(order, func(i, j int) bool {
|
||||
return embySeasonSortOrder(order[i]) < embySeasonSortOrder(order[j])
|
||||
})
|
||||
out := make([]embySeasonGroup, 0, len(order))
|
||||
for _, seasonNum := range order {
|
||||
out = append(out, *bySeason[seasonNum])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -156,6 +157,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) {
|
||||
if got := embyLatestSeriesRowLimit(25); got < 25*40 {
|
||||
t.Fatalf("latest window %d is smaller than the 25x40 fixture", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
|
||||
@@ -209,6 +216,147 @@ func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeparatesOVAAndOADFromSeasonOne(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "动漫", Path: `/media/影视库/动漫`, Type: "anime", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for _, media := range []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "tolove-s01e01"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "出包王女",
|
||||
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru S01E01.mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "tolove-ova01"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "出包王女",
|
||||
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru [OVA01].mkv.strm`,
|
||||
SeasonNum: 0,
|
||||
EpisodeNum: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "tolove-oad01"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "出包王女",
|
||||
Path: `/media/影视库/动漫/出包王女/OAD/To LOVE-Ru Darkness [OAD01].mkv.strm`,
|
||||
SeasonNum: -1,
|
||||
EpisodeNum: 1,
|
||||
},
|
||||
} {
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("library items: %v", err)
|
||||
}
|
||||
rootItems := root["Items"].([]map[string]any)
|
||||
if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" {
|
||||
t.Fatalf("expected one series card, got %#v", rootItems)
|
||||
}
|
||||
if rootItems[0]["ChildCount"] != 3 {
|
||||
t.Fatalf("series ChildCount = %#v, want 3", rootItems[0]["ChildCount"])
|
||||
}
|
||||
|
||||
seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("series seasons: %v", err)
|
||||
}
|
||||
seasonItems := seasons["Items"].([]map[string]any)
|
||||
if len(seasonItems) != 3 {
|
||||
t.Fatalf("expected season 1, OVA and OAD separately, got %#v", seasonItems)
|
||||
}
|
||||
for i, want := range []int{1, embySeasonOVA, embySeasonOAD} {
|
||||
if seasonItems[i]["IndexNumber"] != want {
|
||||
t.Fatalf("season order [%d] = %#v, want %d", i, seasonItems[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
seasonByIndex := make(map[int]map[string]any, len(seasonItems))
|
||||
for _, season := range seasonItems {
|
||||
index, ok := season["IndexNumber"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("season index has unexpected type: %#v", season)
|
||||
}
|
||||
seasonByIndex[index] = season
|
||||
}
|
||||
for index, name := range map[int]string{
|
||||
1: "第 1 季",
|
||||
embySeasonOVA: "OVA",
|
||||
embySeasonOAD: "OAD",
|
||||
} {
|
||||
season := seasonByIndex[index]
|
||||
if season == nil || season["Name"] != name || season["ChildCount"] != 1 {
|
||||
t.Fatalf("season %d = %#v, want name=%q with one episode", index, season, name)
|
||||
}
|
||||
episodes, err := svc.Items(t.Context(), ItemsParams{
|
||||
ParentID: season["Id"].(string),
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("season %d episodes: %v", index, err)
|
||||
}
|
||||
episodeItems := episodes["Items"].([]map[string]any)
|
||||
if len(episodeItems) != 1 || episodeItems[0]["ParentIndexNumber"] != index {
|
||||
t.Fatalf("season %d episodes = %#v", index, episodeItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeasonSortOrderMatchesWeb(t *testing.T) {
|
||||
seasons := []int{
|
||||
embySeasonNCED,
|
||||
embySeasonOAD,
|
||||
2,
|
||||
embySeasonTheatrical,
|
||||
1,
|
||||
embySeasonGenericSpecial,
|
||||
embySeasonOVA,
|
||||
}
|
||||
sort.SliceStable(seasons, func(i, j int) bool {
|
||||
return embySeasonSortOrder(seasons[i]) < embySeasonSortOrder(seasons[j])
|
||||
})
|
||||
want := []int{
|
||||
1,
|
||||
2,
|
||||
embySeasonGenericSpecial,
|
||||
embySeasonTheatrical,
|
||||
embySeasonOVA,
|
||||
embySeasonOAD,
|
||||
embySeasonNCED,
|
||||
}
|
||||
for i := range want {
|
||||
if seasons[i] != want[i] {
|
||||
t.Fatalf("season order = %#v, want %#v", seasons, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeasonCandidatesCoverPersistedSpecialNumbers(t *testing.T) {
|
||||
for _, persisted := range []int{0, -1} {
|
||||
candidates := embySeasonCandidates(persisted)
|
||||
found := map[int]bool{}
|
||||
for _, seasonNum := range candidates {
|
||||
found[seasonNum] = true
|
||||
}
|
||||
if !found[embySeasonOVA] || !found[embySeasonOAD] {
|
||||
t.Fatalf("candidates for persisted season %d = %#v, want OVA and OAD", persisted, candidates)
|
||||
}
|
||||
}
|
||||
if candidates := embySeasonCandidates(2); len(candidates) != 1 || candidates[0] != 2 {
|
||||
t.Fatalf("regular season candidates = %#v, want [2]", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
|
||||
@@ -229,7 +377,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 +444,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 +642,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 +726,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) stri
|
||||
}
|
||||
|
||||
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
|
||||
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
|
||||
return seasonID(e.seriesIDForMedia(ctx, m), embySeasonNumForMedia(m))
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
|
||||
@@ -100,23 +100,150 @@ func stableEmbyID(prefix string, parts ...string) string {
|
||||
return prefix + hex.EncodeToString(h.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func seasonID(seriesID string, seasonNum int) string {
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
const (
|
||||
embySeasonTheatrical = -1
|
||||
embySeasonOVA = -2
|
||||
embySeasonOAD = -3
|
||||
embySeasonOVD = -4
|
||||
embySeasonONA = -5
|
||||
embySeasonExtra = -6
|
||||
embySeasonBonus = -7
|
||||
embySeasonOmake = -8
|
||||
embySeasonPictureDrama = -9
|
||||
embySeasonNCOP = -10
|
||||
embySeasonNCED = -11
|
||||
embySeasonGenericSpecial = 0
|
||||
)
|
||||
|
||||
// embySeasonNumForMedia keeps MeBox's special categories distinct in the Emby
|
||||
// hierarchy. Databases may store every special as season 0 or -1, so the path
|
||||
// classification is authoritative when it identifies a special category.
|
||||
func embySeasonNumForMedia(m *model.Media) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
switch mediaSpecialKind(m.Path) {
|
||||
case mediaSpecialTheatrical:
|
||||
return embySeasonTheatrical
|
||||
case mediaSpecialOVA:
|
||||
return embySeasonOVA
|
||||
case mediaSpecialOAD:
|
||||
return embySeasonOAD
|
||||
case mediaSpecialOVD:
|
||||
return embySeasonOVD
|
||||
case mediaSpecialONA:
|
||||
return embySeasonONA
|
||||
case mediaSpecialExtra:
|
||||
return embySeasonExtra
|
||||
case mediaSpecialBonus:
|
||||
return embySeasonBonus
|
||||
case mediaSpecialOmake:
|
||||
return embySeasonOmake
|
||||
case mediaSpecialPicture:
|
||||
return embySeasonPictureDrama
|
||||
case mediaSpecialNCOP:
|
||||
return embySeasonNCOP
|
||||
case mediaSpecialNCED:
|
||||
return embySeasonNCED
|
||||
case mediaSpecialGeneric:
|
||||
return embySeasonGenericSpecial
|
||||
}
|
||||
if m.SeasonNum < 0 {
|
||||
return embySeasonGenericSpecial
|
||||
}
|
||||
return m.SeasonNum
|
||||
}
|
||||
|
||||
func embySeasonCandidates(seasonNum int) []int {
|
||||
if seasonNum > 0 {
|
||||
return []int{seasonNum}
|
||||
}
|
||||
return []int{
|
||||
embySeasonGenericSpecial,
|
||||
embySeasonTheatrical,
|
||||
embySeasonOVA,
|
||||
embySeasonOAD,
|
||||
embySeasonOVD,
|
||||
embySeasonONA,
|
||||
embySeasonExtra,
|
||||
embySeasonBonus,
|
||||
embySeasonOmake,
|
||||
embySeasonPictureDrama,
|
||||
embySeasonNCOP,
|
||||
embySeasonNCED,
|
||||
}
|
||||
}
|
||||
|
||||
func seasonID(seriesID string, seasonNum int) string {
|
||||
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
|
||||
}
|
||||
|
||||
func seasonName(seasonNum int) string {
|
||||
if seasonNum == 0 {
|
||||
switch seasonNum {
|
||||
case embySeasonGenericSpecial:
|
||||
return "特别篇"
|
||||
}
|
||||
if seasonNum < 0 {
|
||||
seasonNum = 1
|
||||
case embySeasonTheatrical:
|
||||
return "剧场版"
|
||||
case embySeasonOVA:
|
||||
return "OVA"
|
||||
case embySeasonOAD:
|
||||
return "OAD"
|
||||
case embySeasonOVD:
|
||||
return "OVD"
|
||||
case embySeasonONA:
|
||||
return "ONA"
|
||||
case embySeasonExtra:
|
||||
return "Extra"
|
||||
case embySeasonBonus:
|
||||
return "Bonus"
|
||||
case embySeasonOmake:
|
||||
return "Omake"
|
||||
case embySeasonPictureDrama:
|
||||
return "Picture Drama"
|
||||
case embySeasonNCOP:
|
||||
return "NCOP"
|
||||
case embySeasonNCED:
|
||||
return "NCED"
|
||||
}
|
||||
return fmt.Sprintf("第 %d 季", seasonNum)
|
||||
}
|
||||
|
||||
// embySeasonSortOrder mirrors the web UI's seasonSortOrder: regular seasons
|
||||
// first in ascending order, followed by specials in a stable category order.
|
||||
func embySeasonSortOrder(seasonNum int) int {
|
||||
if seasonNum > 0 {
|
||||
return seasonNum
|
||||
}
|
||||
switch seasonNum {
|
||||
case embySeasonGenericSpecial:
|
||||
return 1000
|
||||
case embySeasonTheatrical:
|
||||
return 1001
|
||||
case embySeasonOVA:
|
||||
return 1002
|
||||
case embySeasonOAD:
|
||||
return 1003
|
||||
case embySeasonOVD:
|
||||
return 1004
|
||||
case embySeasonONA:
|
||||
return 1005
|
||||
case embySeasonExtra:
|
||||
return 1006
|
||||
case embySeasonBonus:
|
||||
return 1007
|
||||
case embySeasonOmake:
|
||||
return 1008
|
||||
case embySeasonPictureDrama:
|
||||
return 1009
|
||||
case embySeasonNCOP:
|
||||
return 1010
|
||||
case embySeasonNCED:
|
||||
return 1011
|
||||
default:
|
||||
return 2000
|
||||
}
|
||||
}
|
||||
|
||||
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
|
||||
switch primarySupportedEmbySort(p.SortBy, false) {
|
||||
case "sortname", "name":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP])
|
||||
//
|
||||
// The "1x02" pattern only matches when it is not embedded in a larger number,
|
||||
// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for
|
||||
// season×episode (the old pattern read those as S20E108 / S40E216).
|
||||
//
|
||||
// For bare episode markers such as "EP02", the parser also looks at parent
|
||||
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
|
||||
@@ -14,6 +19,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -22,10 +28,18 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
|
||||
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
|
||||
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`)
|
||||
// patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。
|
||||
// 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p]
|
||||
// 自然不匹配。
|
||||
patBracketEpisode = regexp.MustCompile(`\[0*(\d{1,3})\]`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
@@ -38,6 +52,8 @@ var (
|
||||
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||
// patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。
|
||||
patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`)
|
||||
)
|
||||
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
@@ -53,11 +69,20 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
episode = mustAtoi(m[2])
|
||||
return
|
||||
}
|
||||
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
|
||||
if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 {
|
||||
season = mustAtoi(m[1])
|
||||
episode = mustAtoi(m[2])
|
||||
return
|
||||
}
|
||||
if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 {
|
||||
var found bool
|
||||
season, found = seasonFromParents(path)
|
||||
if !found {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||
return 0, mustAtoi(m[1])
|
||||
}
|
||||
@@ -98,6 +123,77 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
|
||||
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
|
||||
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
|
||||
// when the name carries no such token.
|
||||
//
|
||||
// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO
|
||||
// would inject the wrong identity back even after the parser was fixed.
|
||||
func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) {
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
dims := patResolutionDims.FindStringSubmatch(name)
|
||||
if len(dims) < 3 {
|
||||
return 0, 0, false
|
||||
}
|
||||
m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2])
|
||||
if len(m) != 3 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return mustAtoi(m[1]), mustAtoi(m[2]), true
|
||||
}
|
||||
|
||||
// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the
|
||||
// episode title generated from it) that only exists because a resolution token
|
||||
// was once mistaken for an SxxExx marker.
|
||||
func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
season, episode, ok := resolutionEpisodeArtifact(mediaPath)
|
||||
if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode {
|
||||
return
|
||||
}
|
||||
// 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。
|
||||
// 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 &&
|
||||
parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum {
|
||||
return
|
||||
}
|
||||
meta.SeasonNum = 0
|
||||
meta.EpisodeNum = 0
|
||||
if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) {
|
||||
meta.EpisodeTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" /
|
||||
// "Episode N" form auto-written from an episode number rather than a real name.
|
||||
func isGeneratedEpisodeTitle(title string, episode int) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || episode <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
fmt.Sprintf("第 %d 集", episode),
|
||||
fmt.Sprintf("第%d集", episode),
|
||||
fmt.Sprintf("第 %d 话", episode),
|
||||
fmt.Sprintf("第%d话", episode),
|
||||
fmt.Sprintf("第 %d 話", episode),
|
||||
fmt.Sprintf("第%d話", episode),
|
||||
fmt.Sprintf("Episode %d", episode),
|
||||
fmt.Sprintf("EP%d", episode),
|
||||
} {
|
||||
if strings.EqualFold(title, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||||
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||||
// becomes S00E02. Normal episodes retain their parsed identity.
|
||||
|
||||
@@ -34,6 +34,15 @@ func TestParseEpisode(t *testing.T) {
|
||||
{`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3},
|
||||
{`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4},
|
||||
{"Movie.2020.1080p.mkv", 0, 0},
|
||||
// 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。
|
||||
{"Movie.2020.1920x1080.mkv", 0, 0},
|
||||
{"1920x1080.mkv", 0, 0},
|
||||
{"[Group][Show][02][3840x2160].mkv", 1, 2},
|
||||
// 字幕组方括号集号。
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1},
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12},
|
||||
// 方括号里的年份/分辨率不是集号。
|
||||
{"[Group][Show][2024][1080p].mkv", 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
@@ -92,3 +101,54 @@ func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionEpisodeArtifact(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
wantOK bool
|
||||
}{
|
||||
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true},
|
||||
{"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true},
|
||||
{"Movie.2020.1080p.mkv", 0, 0, false},
|
||||
{"Show.S01E02.mkv", 0, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
season, episode, ok := resolutionEpisodeArtifact(tc.path)
|
||||
if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK {
|
||||
t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
||||
tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
|
||||
// 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。
|
||||
polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"}
|
||||
dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm")
|
||||
if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" {
|
||||
t.Fatalf("resolution artifact not dropped: %+v", polluted)
|
||||
}
|
||||
|
||||
// 真实单集号不受影响。
|
||||
real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"}
|
||||
dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv")
|
||||
if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" {
|
||||
t.Fatalf("real episode identity was modified: %+v", real)
|
||||
}
|
||||
|
||||
// 名称里没有分辨率时不改动任何值。
|
||||
plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
|
||||
dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv")
|
||||
if plain.SeasonNum != 20 || plain.EpisodeNum != 108 {
|
||||
t.Fatalf("unrelated identity was cleared: %+v", plain)
|
||||
}
|
||||
|
||||
// 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。
|
||||
legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
|
||||
dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv")
|
||||
if legit.SeasonNum != 20 || legit.EpisodeNum != 108 {
|
||||
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -55,26 +56,32 @@ func (p *ImageProxy) RemoveFailed(raw string) error {
|
||||
// Serve writes the requested image to w. Caller is expected to validate
|
||||
// the JWT before invoking it.
|
||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
// Emby 客户端用 maxWidth / maxHeight / quality 请求缩略图。不解析这些
|
||||
// 参数就会把多兆字节的原图发给客户端,移动端往往在下载中途超时。
|
||||
opts := parseImageResizeOptions(r)
|
||||
if isLocalImagePath(raw) {
|
||||
return p.serveLocalImage(w, r, raw)
|
||||
return p.serveLocalImage(w, r, raw, opts)
|
||||
}
|
||||
return p.serveRemoteImage(ctx, w, r, raw)
|
||||
return p.serveRemoteImage(ctx, w, r, raw, opts)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
path := filepath.Clean(raw)
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil || !p.isAllowedLocalPath(abs) {
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
if opts.active() && p.serveResizedFromFile(w, r, abs, opts) {
|
||||
return nil
|
||||
}
|
||||
if !serveImageFile(w, r, filepath.Base(abs), abs, imageBrowserCacheControl) {
|
||||
servePlaceholder(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,14 +90,14 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
forceRefresh := r.URL.Query().Get("refresh") != ""
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if !forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
// No negative caching: a previously failed fetch is retried on every
|
||||
// subsequent request, so the image recovers as soon as upstream does.
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
|
||||
if err != nil {
|
||||
if forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
@@ -100,6 +107,10 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 上游原图已落盘,缩放结果复用同一条缓存流水线。
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
if contentLength != "" {
|
||||
w.Header().Set("Content-Length", contentLength)
|
||||
@@ -114,13 +125,48 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveCachedImage 在远程原图已缓存时提供服务。请求带缩放参数时优先命中
|
||||
// 缩放缓存,未命中则从已缓存的原图生成一份;缩放不可用时退回原图直出。
|
||||
func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, key, cachePath string, opts imageResizeOptions) bool {
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return true
|
||||
}
|
||||
return serveCachedImageFile(w, r, key, cachePath)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
|
||||
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
|
||||
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctype := detectContentType(data)
|
||||
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
|
||||
stat, err := file.Stat()
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
headerSize := 512
|
||||
if stat.Size() < int64(headerSize) {
|
||||
headerSize = int(stat.Size())
|
||||
}
|
||||
header := make([]byte, headerSize)
|
||||
n, readErr := io.ReadFull(file, header)
|
||||
_ = file.Close()
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
header = header[:n]
|
||||
// A transparent placeholder is exactly 67 bytes; checking the header alone
|
||||
// is enough for the normal image cache entries (they are much larger but
|
||||
// detectContentType only inspects the same leading 512 bytes anyway).
|
||||
// Close the handle before deleting: Windows refuses to delete an open file.
|
||||
if n > 0 && isImageContentType(detectContentType(header)) &&
|
||||
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "golang.org/x/image/bmp" // register BMP decoder for .bmp/.tbn sidecar art
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for poster art
|
||||
)
|
||||
|
||||
const (
|
||||
imageResizeDefaultQuality = 90
|
||||
imageResizeMinQuality = 1
|
||||
imageResizeMaxQuality = 100
|
||||
|
||||
// imageResizeMaxSourcePixels 限制参与缩放的原图像素总量。解码一张
|
||||
// N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图
|
||||
// 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接
|
||||
// 回退为原图直出,宁可不缩放也不冒 OOM 风险。
|
||||
imageResizeMaxSourcePixels = 30_000_000
|
||||
|
||||
// imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放,
|
||||
// 便于单独清理且不与原始字节流缓存互相覆盖。
|
||||
imageResizeCacheSubdir = "resized"
|
||||
)
|
||||
|
||||
// imageResizeOptions 描述客户端通过图片 URL 查询参数请求的目标尺寸。
|
||||
// Emby / Infuse / Yamby / RodelPlayer 等客户端普遍使用
|
||||
// maxWidth / maxHeight / quality,也有客户端使用 width / height。
|
||||
type imageResizeOptions struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// active 报告是否需要缩放。没有任何尺寸参数时返回 false,调用方保持
|
||||
// 原有的原图直出路径(支持 Range / ETag,行为完全不变)。
|
||||
func (o imageResizeOptions) active() bool {
|
||||
return o.MaxWidth > 0 || o.MaxHeight > 0
|
||||
}
|
||||
|
||||
// encodingQuality 返回生效的 JPEG 编码质量,缺省 90。
|
||||
func (o imageResizeOptions) encodingQuality() int {
|
||||
q := o.Quality
|
||||
if q < imageResizeMinQuality || q > imageResizeMaxQuality {
|
||||
return imageResizeDefaultQuality
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// parseImageResizeOptions 从请求查询串解析缩放参数。Emby 客户端对参数名
|
||||
// 大小写不敏感,这里逐项做 EqualFold 匹配。
|
||||
func parseImageResizeOptions(r *http.Request) imageResizeOptions {
|
||||
if r == nil || r.URL == nil {
|
||||
return imageResizeOptions{}
|
||||
}
|
||||
q := r.URL.Query()
|
||||
return imageResizeOptions{
|
||||
MaxWidth: firstPositiveQueryInt(q, "maxWidth", "width"),
|
||||
MaxHeight: firstPositiveQueryInt(q, "maxHeight", "height"),
|
||||
Quality: firstPositiveQueryInt(q, "quality"),
|
||||
}
|
||||
}
|
||||
|
||||
// firstPositiveQueryInt 按顺序返回第一个能解析为正数的查询参数。
|
||||
func firstPositiveQueryInt(q url.Values, names ...string) int {
|
||||
for _, name := range names {
|
||||
for key, values := range q {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
// Emby 客户端偶发传入 "400.0" 这类浮点字面量。
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fitSize 按等比缩放把 srcW x srcH 装进 maxW/maxH 边界,且从不放大。
|
||||
func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return srcW, srcH
|
||||
}
|
||||
scale := 1.0
|
||||
if maxW > 0 && srcW > maxW {
|
||||
scale = math.Min(scale, float64(maxW)/float64(srcW))
|
||||
}
|
||||
if maxH > 0 && srcH > maxH {
|
||||
scale = math.Min(scale, float64(maxH)/float64(srcH))
|
||||
}
|
||||
if scale >= 1 {
|
||||
return srcW, srcH
|
||||
}
|
||||
dstW := int(math.Round(float64(srcW) * scale))
|
||||
dstH := int(math.Round(float64(srcH) * scale))
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
return dstW, dstH
|
||||
}
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
// 像素后统一转 JPEG —— 客户端本来就只按缩略图显示。
|
||||
if strings.EqualFold(format, "png") && !isOpaqueImage(dst) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/png", false, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: o.encodingQuality()}); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", false, nil
|
||||
}
|
||||
|
||||
// isOpaqueImage 逐像素确认图像不含透明像素。
|
||||
func isOpaqueImage(img *image.RGBA) bool {
|
||||
bounds := img.Bounds()
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 +
|
||||
// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。
|
||||
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality())
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireResizeSlot 不等待。槽位忙时返回 false,调用方应改出原图。
|
||||
func (p *ImageProxy) tryAcquireResizeSlot() (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
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
|
||||
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
|
||||
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
|
||||
if size > 0 && size <= resizeQueueBypassBytes {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
return p.acquireResizeSlot(ctx)
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||
func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request, srcPath string, o imageResizeOptions) bool {
|
||||
stat, err := os.Stat(srcPath)
|
||||
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
|
||||
}
|
||||
// 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的
|
||||
// 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。
|
||||
if serveCompactOriginal(w, r, srcPath, stat) {
|
||||
return true
|
||||
}
|
||||
|
||||
release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size())
|
||||
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
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, o)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if unchanged {
|
||||
// 原图已经在目标尺寸内:直接流式输出,保留 ETag / Range 语义。
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
p.writeResizeCache(cachePath, out)
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||
http.ServeContent(w, r, key, stat.ModTime(), bytes.NewReader(out))
|
||||
return true
|
||||
}
|
||||
|
||||
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
|
||||
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
|
||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||
const compactImageSkipBytes = 200 * 1024
|
||||
|
||||
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||
// 以免把未压缩的剧照直接发给客户端。
|
||||
const resizeQueueBypassBytes = 1536 * 1024
|
||||
|
||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||
if stat == nil || stat.Size() <= 0 || stat.Size() > compactImageSkipBytes {
|
||||
return false
|
||||
}
|
||||
file, err := os.Open(srcPath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var header [12]byte
|
||||
n, _ := file.Read(header[:])
|
||||
_ = file.Close()
|
||||
if !isCompactWebImage(header[:n]) {
|
||||
return false
|
||||
}
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
func isCompactWebImage(header []byte) bool {
|
||||
if len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff {
|
||||
return true
|
||||
}
|
||||
return len(header) >= 12 &&
|
||||
string(header[0:4]) == "RIFF" &&
|
||||
string(header[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||
dir := filepath.Dir(cachePath)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
p.warn("imageproxy: resize cache mkdir failed", err)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
tmp, err := os.CreateTemp(dir, "resized-*.tmp")
|
||||
if err != nil {
|
||||
p.warn("imageproxy: resize cache temp failed", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), cachePath); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImageProxy) warn(msg string, err error) {
|
||||
if p == nil || p.log == nil {
|
||||
return
|
||||
}
|
||||
p.log.Warn(msg, zap.Error(err))
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
||||
func encodeTestPNG(t *testing.T, w, h int, alpha uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: alpha})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsReadsEmbyParams(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/emby/Items/x/Images/Primary?maxWidth=400&maxHeight=600&quality=80", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 400 || o.MaxHeight != 600 || o.Quality != 80 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
if !o.active() {
|
||||
t.Fatal("expected options to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsIsCaseInsensitive(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?MaxWidth=250&QUALITY=70", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 250 {
|
||||
t.Fatalf("MaxWidth = %d, want 250", o.MaxWidth)
|
||||
}
|
||||
if o.Quality != 70 {
|
||||
t.Fatalf("Quality = %d, want 70", o.Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsAcceptsWidthAndHeightAliases(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?width=320&height=180", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 320 || o.MaxHeight != 180 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsInactiveWithoutDimensions(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?quality=90&tag=abc", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.active() {
|
||||
t.Fatalf("expected inactive options, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitSizePreservesAspectAndNeverUpscales(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
srcW, srcH, maxW, maxH int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{"max-width-only", 529, 911, 400, 0, 400, 689},
|
||||
{"max-height-only", 529, 911, 0, 500, 290, 500},
|
||||
{"never-upscales", 529, 911, 2000, 2000, 529, 911},
|
||||
{"both-bounds", 1000, 500, 400, 400, 400, 200},
|
||||
{"exact-size", 400, 600, 400, 600, 400, 600},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotW, gotH := fitSize(tc.srcW, tc.srcH, tc.maxW, tc.maxH)
|
||||
if gotW != tc.wantW || gotH != tc.wantH {
|
||||
t.Fatalf("fitSize(%d,%d,%d,%d) = %dx%d, want %dx%d",
|
||||
tc.srcW, tc.srcH, tc.maxW, tc.maxH, gotW, gotH, tc.wantW, tc.wantH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 529, 911, 255)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if cfg.Width != 400 || cfg.Height != 689 {
|
||||
t.Fatalf("resized to %dx%d, want 400x689", cfg.Width, cfg.Height)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
// 这里只断言"重新编码生效且输出可解码"。合成图的压缩率不代表真实海报
|
||||
// (规则色块 PNG 极小,而 JPEG 压规则图案反而更大);真实海报的体积
|
||||
// 收益在服务器上用线上素材实测。
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected a non-empty resized image")
|
||||
}
|
||||
if len(out) == len(data) {
|
||||
t.Fatal("expected the resized image to be re-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataKeepsTransparencyAsPNG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 600, 900, 128)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 300, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/png" {
|
||||
t.Fatalf("content type = %q, want image/png so transparency is preserved", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if format != "png" {
|
||||
t.Fatalf("format = %q, want png", format)
|
||||
}
|
||||
if cfg.Width != 300 || cfg.Height != 450 {
|
||||
t.Fatalf("resized to %dx%d, want 300x450", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsCacheKeyVariesWithParametersAndSource(t *testing.T) {
|
||||
stat, err := os.Stat("image_resize_test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
base := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
if base.resizeCacheKey("a", stat) != base.resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must be stable for identical inputs")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == base.resizeCacheKey("b", stat) {
|
||||
t.Fatal("cache key must differ between sources")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 200, Quality: 90}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when max width changes")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 400, Quality: 60}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when quality changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsEncodingQualityFallsBackToDefault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{0, imageResizeDefaultQuality},
|
||||
{-5, imageResizeDefaultQuality},
|
||||
{500, imageResizeDefaultQuality},
|
||||
{1, 1},
|
||||
{100, 100},
|
||||
{75, 75},
|
||||
} {
|
||||
if got := (imageResizeOptions{Quality: tc.in}).encodingQuality(); got != tc.want {
|
||||
t.Fatalf("encodingQuality(%d) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileCachesScaledResult(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"
|
||||
if err := os.WriteFile(src, encodeTestPNG(t, 529, 911, 255), 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 serveResizedFromFile to handle the request")
|
||||
}
|
||||
if got := first.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", got)
|
||||
}
|
||||
|
||||
// 第二次请求应命中磁盘缓存,返回与首次完全相同的字节。
|
||||
second := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected second call to be served")
|
||||
}
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
|
||||
t.Fatal("expected the cached scaled image to be reused")
|
||||
}
|
||||
if len(first.Body.Bytes()) == 0 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "poster.jpg"
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 800, 1200)), &jpeg.Options{Quality: 40}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 || buf.Len() > compactImageSkipBytes {
|
||||
t.Fatalf("test jpeg is %d bytes, want a compact poster", buf.Len())
|
||||
}
|
||||
if err := os.WriteFile(src, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=120", nil), src, imageResizeOptions{MaxWidth: 120}) {
|
||||
t.Fatal("expected compact jpeg to be served")
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), buf.Bytes()) {
|
||||
t.Fatal("expected the original jpeg, not a decoded thumbnail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "backdrop.jpg"
|
||||
data := make([]byte, compactImageSkipBytes+1024)
|
||||
data[0], data[1], data[2] = 0xff, 0xd8, 0xff
|
||||
if err := os.WriteFile(src, data, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("expected a resize slot")
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
defer func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Fatal("resize slot was busy but the request still waited in the queue")
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +74,15 @@ func localPosterCandidates(mediaPath string) []string {
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show"} {
|
||||
add(name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
add("thumb")
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
@@ -98,7 +101,21 @@ func localBackdropCandidates(mediaPath string) []string {
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
_, episode := ParseEpisode(mediaPath)
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
// Jellyfin/Emby commonly stores an episode still beside the video as
|
||||
// "<episode>-thumb.jpg" or "<episode>.jpg". Episode-specific artwork
|
||||
// must win over a series-level fanart.jpg in the parent folder,
|
||||
// otherwise a rescan flattens every episode to the same backdrop.
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
if episode > 0 {
|
||||
add(base + "-still")
|
||||
add(base + ".still")
|
||||
add(base + "-scene")
|
||||
add(base + ".scene")
|
||||
add(base)
|
||||
}
|
||||
add(base + "-fanart")
|
||||
add(base + ".fanart")
|
||||
add(base + "-backdrop")
|
||||
@@ -155,7 +172,7 @@ func firstExistingImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -218,7 +235,7 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
||||
if isRejectedPosterName(name) {
|
||||
continue
|
||||
}
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) && likelyPosterImage(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -237,7 +254,7 @@ func firstAdultLooseImage(dir, kind string) string {
|
||||
fallback := []string{}
|
||||
for _, path := range matches {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" {
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" && ext != ".img" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -116,6 +119,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
}
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,48 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
|
||||
// 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO;
|
||||
// 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "彼得·格里尔的贤者时间")
|
||||
if err := os.MkdirAll(showDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(showDir,
|
||||
"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<episodedetails>
|
||||
<title>第 108 集</title>
|
||||
<showtitle>彼得·格里尔的贤者时间</showtitle>
|
||||
<season>20</season>
|
||||
<episode>108</episode>
|
||||
<tmdbid>99080</tmdbid>
|
||||
</episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil")
|
||||
}
|
||||
if got.SeasonNum != 0 || got.EpisodeNum != 0 {
|
||||
t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum)
|
||||
}
|
||||
if got.EpisodeTitle != "" {
|
||||
t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle)
|
||||
}
|
||||
if got.Title != "彼得·格里尔的贤者时间" {
|
||||
t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMovieMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
|
||||
@@ -194,8 +236,8 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
|
||||
if got.PosterURL != showPoster {
|
||||
t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
|
||||
}
|
||||
if got.BackdropURL != backdrop {
|
||||
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
|
||||
if got.BackdropURL != episodeThumb {
|
||||
t.Fatalf("BackdropURL = %q, want episode thumb %q, not series backdrop %q", got.BackdropURL, episodeThumb, backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,3 +417,22 @@ func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) {
|
||||
t.Fatalf("expected episode artwork fallback, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Jigokuraku.S01E14.mkv.strm")
|
||||
poster := filepath.Join(root, "Jigokuraku.S01E14-poster.img")
|
||||
if err := os.WriteFile(mediaPath, []byte("https://example.test/video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(poster, testJPEG, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster {
|
||||
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
|
||||
return "media:list:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string {
|
||||
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
|
||||
libIDs := make([]string, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libIDs[i] = lib.ID
|
||||
@@ -48,7 +48,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim
|
||||
sum := sha1.Sum([]byte(strings.Join([]string{
|
||||
"preview",
|
||||
strings.Join(libIDs, ","),
|
||||
fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW),
|
||||
fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts),
|
||||
strings.Join(allowed, ","),
|
||||
strings.Join(hidden, ","),
|
||||
}, "|")))
|
||||
@@ -69,6 +69,23 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
|
||||
return "media:series-cards:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter repository.MediaQueryFilter) string {
|
||||
ids := append([]string(nil), libraryIDs...)
|
||||
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
|
||||
sort.Strings(ids)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
sum := sha1.Sum([]byte(strings.Join([]string{
|
||||
"counts",
|
||||
strings.Join(ids, ","),
|
||||
fmt.Sprintf("%t", filter.IncludeNSFW),
|
||||
strings.Join(allowed, ","),
|
||||
strings.Join(hidden, ","),
|
||||
}, "|")))
|
||||
return "media:lib-counts:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *MediaService) mediaCacheTTLSeconds() int {
|
||||
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
|
||||
return 90
|
||||
@@ -82,20 +99,36 @@ func (s *MediaService) mediaObjectTTL() time.Duration {
|
||||
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
|
||||
}
|
||||
|
||||
// derivedReadCacheTTL 给首页预览、剧集分组、库计数这类派生读缓存一个更长的
|
||||
// 下限。扫描/刮削/删改都会走 invalidateMediaCache 清掉 media: 前缀,所以
|
||||
// 拉长 TTL 不会在写入后继续吐旧卡片。配置里的 TTL 更长时仍以配置为准。
|
||||
func (s *MediaService) derivedReadCacheTTL() time.Duration {
|
||||
ttl := s.mediaObjectTTL()
|
||||
const floor = 10 * time.Minute
|
||||
if ttl < floor {
|
||||
return floor
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func hashObjectCacheKey(parts []string) string {
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// mediaGroupedRowsCacheKey 版本分组源行(已挂库元数据)的对象缓存。
|
||||
func (s *MediaService) mediaGroupedRowsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
|
||||
// groupedItemsCacheKey caches the fully grouped version list. Version grouping
|
||||
// requires scanning every row in the library, so recomputing it for each page
|
||||
// request makes pagination O(pages × library size) instead of O(library size).
|
||||
// Only the grouped result is cached; caching the ungrouped rows as well would
|
||||
// duplicate large libraries in the process memory for no additional hit path.
|
||||
func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
|
||||
libs := append([]string(nil), libraryIDs...)
|
||||
sort.Strings(libs)
|
||||
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
return "media:obj:grouped-rows:" + hashObjectCacheKey([]string{
|
||||
return "media:obj:grouped-items:" + hashObjectCacheKey([]string{
|
||||
libraryID,
|
||||
strings.Join(libs, ","),
|
||||
fmt.Sprintf("%t", filter.IncludeNSFW),
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
@@ -12,14 +13,12 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
|
||||
if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
libs, err := s.repo.Library.List(ctx)
|
||||
libs, err := s.displayLibraries(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
byID := make(map[string]model.Library, len(libs))
|
||||
for i := range libs {
|
||||
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
|
||||
lib := libs[i]
|
||||
for _, lib := range libs {
|
||||
byID[lib.ID] = lib
|
||||
}
|
||||
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
|
||||
@@ -44,11 +43,39 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaService) displayLibraries(ctx context.Context) ([]model.Library, error) {
|
||||
const cacheKey = "media:obj:library-metadata"
|
||||
if s.cache != nil {
|
||||
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
|
||||
if cached, ok := cachedObj.([]model.Library); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
libs, err := s.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range libs {
|
||||
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, libs, s.mediaObjectTTL())
|
||||
}
|
||||
return libs, nil
|
||||
}
|
||||
|
||||
type mediaDisplayLibraryResolver struct {
|
||||
byID map[string]model.Library
|
||||
displayByID map[string]model.Library
|
||||
displayByMergeKey map[string]model.Library
|
||||
displayLibraries []model.Library
|
||||
localDisplays []mediaLocalDisplayLibrary
|
||||
}
|
||||
|
||||
type mediaLocalDisplayLibrary struct {
|
||||
library model.Library
|
||||
path string
|
||||
}
|
||||
|
||||
func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
|
||||
@@ -58,6 +85,7 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
|
||||
displayByID: make(map[string]model.Library, len(displayLibraries)),
|
||||
displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
|
||||
displayLibraries: displayLibraries,
|
||||
localDisplays: make([]mediaLocalDisplayLibrary, 0, len(displayLibraries)),
|
||||
}
|
||||
for _, lib := range libs {
|
||||
normalized := normalizeLocalLibraryPathForDisplay(lib)
|
||||
@@ -70,7 +98,23 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
|
||||
resolver.displayByMergeKey[key] = lib
|
||||
}
|
||||
}
|
||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
displayPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
|
||||
if displayPath == "" || displayPath == "." {
|
||||
continue
|
||||
}
|
||||
resolver.localDisplays = append(resolver.localDisplays, mediaLocalDisplayLibrary{
|
||||
library: lib,
|
||||
path: displayPath,
|
||||
})
|
||||
}
|
||||
// 最长路径优先:一次命中就是原逻辑中最具体的媒体库,避免每条媒体都
|
||||
// 重新规范化全部库路径并扫描整个库列表。
|
||||
sort.SliceStable(resolver.localDisplays, func(i, j int) bool {
|
||||
return len(resolver.localDisplays[i].path) > len(resolver.localDisplays[j].path)
|
||||
})
|
||||
return resolver
|
||||
}
|
||||
|
||||
@@ -172,26 +216,10 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (
|
||||
if isRelativeVolumeMarkerPath(media.Path) {
|
||||
mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path))
|
||||
}
|
||||
var best model.Library
|
||||
bestLen := 0
|
||||
for _, lib := range r.displayLibraries {
|
||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
|
||||
continue
|
||||
for _, display := range r.localDisplays {
|
||||
if mediaPath == display.path || strings.HasPrefix(mediaPath, strings.TrimRight(display.path, "/")+"/") {
|
||||
return display.library, true
|
||||
}
|
||||
libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
|
||||
if libPath == "" || libPath == "." {
|
||||
continue
|
||||
}
|
||||
if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
|
||||
continue
|
||||
}
|
||||
if len(libPath) > bestLen {
|
||||
best = lib
|
||||
bestLen = len(libPath)
|
||||
}
|
||||
}
|
||||
if bestLen > 0 {
|
||||
return best, true
|
||||
}
|
||||
return model.Library{}, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestMediaDisplayLibraryResolverUsesMostSpecificPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parentPath := filepath.Join(root, "media")
|
||||
nestedPath := filepath.Join(parentPath, "anime")
|
||||
parent := model.Library{
|
||||
Base: model.Base{ID: "parent"},
|
||||
Name: "媒体",
|
||||
Path: parentPath,
|
||||
Enabled: true,
|
||||
}
|
||||
nested := model.Library{
|
||||
Base: model.Base{ID: "nested"},
|
||||
Name: "动漫",
|
||||
Path: nestedPath,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
resolver := newMediaDisplayLibraryResolver(t.Context(), nil, []model.Library{parent, nested})
|
||||
got, ok := resolver.DisplayLibraryForMedia(model.Media{
|
||||
LibraryID: parent.ID,
|
||||
Path: filepath.Join(nestedPath, "Show", "S01E01.mkv"),
|
||||
})
|
||||
if !ok || got.ID != nested.ID {
|
||||
t.Fatalf("display library = %q, %t; want nested library", got.ID, ok)
|
||||
}
|
||||
|
||||
got, ok = resolver.DisplayLibraryForMedia(model.Media{
|
||||
LibraryID: parent.ID,
|
||||
Path: filepath.Join(parentPath, "Movie", "Movie.mkv"),
|
||||
})
|
||||
if !ok || got.ID != parent.ID {
|
||||
t.Fatalf("display library = %q, %t; want parent library", got.ID, ok)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
@@ -24,8 +23,44 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro
|
||||
return s.repo.Library.List(ctx)
|
||||
}
|
||||
|
||||
// CountLibrariesCached returns per-library media totals. The homepage metadata
|
||||
// request asks for every library, and the underlying COUNT is repeated on each
|
||||
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
|
||||
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
|
||||
if len(libraryIDs) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
|
||||
var cached map[string]int64
|
||||
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if counts == nil {
|
||||
counts = map[string]int64{}
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
||||
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
|
||||
}
|
||||
|
||||
// ListLibraryPreviews returns only the latest preview cards. The metadata
|
||||
// endpoint already returns totals, so preview batches used by the home and
|
||||
// library pages can skip an otherwise repeated COUNT(*) over every library.
|
||||
func (s *MediaService) ListLibraryPreviews(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, false)
|
||||
}
|
||||
|
||||
func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int, includeCounts bool) ([]LibraryPreviewItem, error) {
|
||||
if cardLimit <= 0 {
|
||||
cardLimit = 10
|
||||
}
|
||||
@@ -40,7 +75,7 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter)
|
||||
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts)
|
||||
var cached libraryPreviewCacheValue
|
||||
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached.Items, nil
|
||||
@@ -56,22 +91,26 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
||||
libIDs = append(libIDs, lib.ID)
|
||||
}
|
||||
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if total, ok := counts[out[i].ID]; ok {
|
||||
out[i].Total = total
|
||||
if includeCounts {
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
if total, ok := counts[out[i].ID]; ok {
|
||||
out[i].Total = total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchCount := cardLimit * 4
|
||||
if fetchCount < 60 {
|
||||
fetchCount = 60
|
||||
} else if fetchCount > 200 {
|
||||
fetchCount = 200
|
||||
// Preview rows are only an internal candidate window. Keep it bounded so a
|
||||
// library with a very long series cannot turn a homepage request into a full
|
||||
// 50k-row scan merely to find another distinct card.
|
||||
fetchCount := cardLimit * 12
|
||||
if fetchCount < 120 {
|
||||
fetchCount = 120
|
||||
} else if fetchCount > 400 {
|
||||
fetchCount = 400
|
||||
}
|
||||
|
||||
recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter)
|
||||
@@ -81,9 +120,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
||||
|
||||
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
|
||||
for i := range out {
|
||||
if out[i].Total == 0 {
|
||||
continue
|
||||
}
|
||||
items := recentByLibrary[out[i].ID]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
@@ -93,32 +129,22 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
||||
s.attachLibraryMetadata(ctx, allPreviewItems)
|
||||
|
||||
for i := range out {
|
||||
if out[i].Total == 0 {
|
||||
continue
|
||||
}
|
||||
items := recentByLibrary[out[i].ID]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
|
||||
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
|
||||
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
|
||||
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
|
||||
cards = fullCards
|
||||
}
|
||||
}
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
|
||||
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
|
||||
@@ -155,3 +155,28 @@ func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) {
|
||||
t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibraryPreviewsSkipsTotals(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "预览电影",
|
||||
Path: "/media/movies/预览电影.mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
previews, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(previews) != 1 || previews[0].Total != 0 || len(previews[0].Cards) != 1 {
|
||||
t.Fatalf("preview-only result = %#v, want one card and no count", previews)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,45 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
|
||||
|
||||
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
|
||||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
||||
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
|
||||
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
grouped := groupMediaVersions(items)
|
||||
return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil
|
||||
}
|
||||
|
||||
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
|
||||
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
|
||||
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
|
||||
if s.cache != nil {
|
||||
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
|
||||
if cached, ok := cachedObj.([]MediaItem); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := groupMediaVersions(items)
|
||||
if s.cache != nil && len(grouped) > 0 {
|
||||
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
|
||||
}
|
||||
return grouped, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||
@@ -75,30 +106,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
cacheKey := s.mediaGroupedRowsCacheKey(libraryID, libraryIDs, filter)
|
||||
if s.cache != nil {
|
||||
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
|
||||
if cached, ok := cachedObj.([]model.Media); ok {
|
||||
// 对象缓存中的切片视为不可变;attachLibraryMetadata 会在填充时
|
||||
// 执行过,命中路径直接返回副本即可(调用方只读)。
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
|
||||
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
|
||||
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
|
||||
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
|
||||
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total > int64(len(items)) && s.log != nil {
|
||||
s.log.Warn("media version grouping truncated by safety limit",
|
||||
if len(items) >= maxMediaSearchLimit && s.log != nil {
|
||||
s.log.Warn("media version grouping may be truncated by safety limit",
|
||||
zap.String("library_id", libraryID),
|
||||
zap.Int64("total", total),
|
||||
zap.Int("limit", maxMediaSearchLimit))
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, items, s.mediaObjectTTL())
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// strmPathAliases returns sibling STRM names that can represent the same media
|
||||
// item when keep_ext is toggled on or off.
|
||||
//
|
||||
// Both directions are supported:
|
||||
//
|
||||
// foo.strm <-> foo.mkv.strm
|
||||
// foo.strm <-> foo.mp4.strm
|
||||
//
|
||||
// The caller must still verify that the aliases are no longer present on disk.
|
||||
// keep_ext intentionally allows foo.mkv.strm and foo.mp4.strm to coexist as
|
||||
// two real versions, so an existing sibling must never be absorbed.
|
||||
func strmPathAliases(path string) []string {
|
||||
clean := filepath.Clean(strings.TrimSpace(path))
|
||||
if clean == "" || clean == "." || !strings.EqualFold(filepath.Ext(clean), ".strm") {
|
||||
return nil
|
||||
}
|
||||
base := mediaSidecarBase(clean)
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(clean)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
lastExt := strings.ToLower(filepath.Ext(stem))
|
||||
_, hasVideoExt := videoExtensions[lastExt]
|
||||
hasVideoExt = hasVideoExt && !strings.EqualFold(lastExt, ".strm")
|
||||
|
||||
dir := filepath.Dir(clean)
|
||||
out := make([]string, 0, len(videoExtensions)+1)
|
||||
seen := make(map[string]struct{}, len(videoExtensions)+1)
|
||||
add := func(candidate string) {
|
||||
candidate = filepath.Clean(candidate)
|
||||
if candidate == "" || candidate == "." || samePath(candidate, clean) {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
if hasVideoExt {
|
||||
// Current keep_ext shape: the only legacy shape is the stripped name.
|
||||
add(filepath.Join(dir, base+".strm"))
|
||||
return out
|
||||
}
|
||||
// Stripped shape: an old keep_ext file may use any configured video ext.
|
||||
exts := make([]string, 0, len(videoExtensions))
|
||||
for ext := range videoExtensions {
|
||||
if strings.EqualFold(ext, ".strm") {
|
||||
continue
|
||||
}
|
||||
exts = append(exts, strings.ToLower(ext))
|
||||
}
|
||||
sort.Strings(exts)
|
||||
for _, ext := range exts {
|
||||
add(filepath.Join(dir, base+ext+".strm"))
|
||||
if upper := strings.ToUpper(ext); upper != ext {
|
||||
add(filepath.Join(dir, base+upper+".strm"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingSTRMPathAliases keeps only aliases which no longer exist on disk.
|
||||
// This is the key protection for keep_ext=true multi-version libraries: an
|
||||
// existing foo.mp4.strm is a real second version, not a rename tombstone.
|
||||
func missingSTRMPathAliases(path string) []string {
|
||||
aliases := strmPathAliases(path)
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
if _, err := os.Lstat(alias); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
out = append(out, alias)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func liveSTRMPathAlias(path string) bool {
|
||||
for _, alias := range strmPathAliases(path) {
|
||||
if info, err := os.Stat(alias); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type seriesCardsCacheValue struct {
|
||||
@@ -24,6 +25,7 @@ type libraryRowsCacheValue struct {
|
||||
Rows []model.Media
|
||||
Resolver mediaSeriesKeyResolver
|
||||
Episodes map[string][]model.Media
|
||||
Cards []SeriesCard
|
||||
}
|
||||
|
||||
type SeriesCard struct {
|
||||
@@ -41,6 +43,7 @@ type seriesCardGroup struct {
|
||||
|
||||
// libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。
|
||||
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
|
||||
if s.cache != nil {
|
||||
if obj, ok := s.cache.GetObject(cacheKey); ok {
|
||||
@@ -55,24 +58,24 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
|
||||
}
|
||||
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
|
||||
// key 计算依赖 DisplayLibraryPath/ID)。
|
||||
resolver := newMediaSeriesKeyResolver(rows)
|
||||
resolver, keys := resolveMediaSeriesKeys(rows)
|
||||
episodes := make(map[string][]model.Media, len(rows)/4+1)
|
||||
for _, row := range rows {
|
||||
k := resolver.key(row)
|
||||
for i, row := range rows {
|
||||
k := keys[i]
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
episodes[k] = append(episodes[k], row)
|
||||
}
|
||||
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes}
|
||||
cards := groupMediaSeriesCardsByKeys(rows, keys)
|
||||
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
|
||||
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
cacheKey := s.libraryCardsObjectKey(libraryID, visibility)
|
||||
if s.cache != nil {
|
||||
if obj, ok := s.cache.GetObject(cacheKey); ok {
|
||||
@@ -85,13 +88,18 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
cards := groupMediaSeriesCards(rows.Rows)
|
||||
cards := rows.Cards
|
||||
if cards == nil && len(rows.Rows) > 0 {
|
||||
// Fallback keeps rolling-cache compatibility if an older runtime cache
|
||||
// value was created before Cards was added.
|
||||
cards = groupMediaSeriesCards(rows.Rows)
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
total := int64(len(cards))
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
|
||||
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
|
||||
}
|
||||
return cards, total, nil
|
||||
}
|
||||
@@ -190,40 +198,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
parentBase := filepath.Base(targetDir)
|
||||
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
|
||||
targetTitleNorm := normalizeSeriesTitle(target.Title)
|
||||
targetDirNorm := normalizeSeriesTitle(parentBase)
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
|
||||
continue
|
||||
}
|
||||
if row.ID == target.ID {
|
||||
dirMatches = append(dirMatches, row)
|
||||
continue
|
||||
}
|
||||
rowTitleNorm := normalizeSeriesTitle(row.Title)
|
||||
allowMatch := false
|
||||
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
|
||||
allowMatch = true
|
||||
}
|
||||
if allowMatch {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
parentBase := filepath.Base(targetDir)
|
||||
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
|
||||
targetTitleNorm := normalizeSeriesTitle(target.Title)
|
||||
targetDirNorm := normalizeSeriesTitle(parentBase)
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
|
||||
continue
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
if row.ID == target.ID {
|
||||
dirMatches = append(dirMatches, row)
|
||||
continue
|
||||
}
|
||||
rowTitleNorm := normalizeSeriesTitle(row.Title)
|
||||
allowMatch := false
|
||||
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
|
||||
allowMatch = true
|
||||
}
|
||||
if allowMatch {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
@@ -246,35 +254,47 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
}
|
||||
|
||||
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||
const pageSize = 2000
|
||||
var all []model.Media
|
||||
var total int64
|
||||
for page := 1; ; page++ {
|
||||
rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page == 1 {
|
||||
total = n
|
||||
all = make([]model.Media, 0, minInt64(n, pageSize))
|
||||
}
|
||||
all = append(all, rows...)
|
||||
if int64(len(all)) >= n || len(rows) < pageSize {
|
||||
break
|
||||
}
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return all, total, nil
|
||||
filter := repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
}
|
||||
rows, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
return rows, int64(len(rows)), nil
|
||||
}
|
||||
|
||||
func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, keys := resolveMediaSeriesKeys(items)
|
||||
return groupMediaSeriesCardsByKeys(items, keys)
|
||||
}
|
||||
|
||||
// groupMediaSeriesCardsByKeys performs the card fold using already-resolved
|
||||
// keys. Full-library caches need the same keys for the episode index and the
|
||||
// series-card list; resolving them once avoids several regex-heavy passes over
|
||||
// every episode row.
|
||||
func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCard {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(keys) != len(items) {
|
||||
return groupMediaSeriesCards(items)
|
||||
}
|
||||
groups := make([]seriesCardGroup, 0)
|
||||
byKey := make(map[string]int, len(items))
|
||||
resolver := newMediaSeriesKeyResolver(items)
|
||||
for _, item := range items {
|
||||
key := resolver.key(item)
|
||||
for i, item := range items {
|
||||
key := keys[i]
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
@@ -301,16 +321,8 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
if betterSeriesLinkMedia(item, card.LinkMedia) {
|
||||
card.LinkMedia = item
|
||||
}
|
||||
currentArtwork := seriesArtworkScore(item)
|
||||
representativeArtwork := seriesArtworkScore(card.Rep)
|
||||
if currentArtwork > representativeArtwork {
|
||||
if betterSeriesRepresentative(item, card.Rep) {
|
||||
card.Rep = item
|
||||
} else if currentArtwork == representativeArtwork {
|
||||
cur := item.SeasonNum*10000 + item.EpisodeNum
|
||||
rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum
|
||||
if cur > 0 && (rep == 0 || cur < rep) {
|
||||
card.Rep = item
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -335,6 +347,43 @@ 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
|
||||
// the visible identity of a mixed series card so a movie cannot hijack the
|
||||
// series title, overview, and artwork.
|
||||
candidateTheatrical := mediaLooksLikeTheatricalFeature(&candidate)
|
||||
currentTheatrical := mediaLooksLikeTheatricalFeature(¤t)
|
||||
if candidateTheatrical != currentTheatrical {
|
||||
return !candidateTheatrical
|
||||
}
|
||||
|
||||
currentArtwork := seriesArtworkScore(candidate)
|
||||
representativeArtwork := seriesArtworkScore(current)
|
||||
if currentArtwork != representativeArtwork {
|
||||
return currentArtwork > representativeArtwork
|
||||
}
|
||||
cur := candidate.SeasonNum*10000 + candidate.EpisodeNum
|
||||
rep := current.SeasonNum*10000 + current.EpisodeNum
|
||||
return cur > 0 && (rep == 0 || cur < rep)
|
||||
}
|
||||
|
||||
func seriesMediaTime(media model.Media) time.Time {
|
||||
if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil {
|
||||
|
||||
@@ -14,72 +14,123 @@ type mediaSeriesKeyResolver struct {
|
||||
pathTitles map[string]string
|
||||
}
|
||||
|
||||
// mediaSeriesKeyInputs 保存单条媒体解析剧集 key 所需的派生字段。剧集卡片、
|
||||
// 剧集索引和分页分组需要反复读取同一批行;提前计算一次可以避免对每行执行
|
||||
// 多轮正则匹配、路径解析和标题规范化。
|
||||
type mediaSeriesKeyInputs struct {
|
||||
episodic bool
|
||||
pathKey string
|
||||
externalKey string
|
||||
titleKey string
|
||||
}
|
||||
|
||||
func analyzeMediaSeriesKey(item model.Media) mediaSeriesKeyInputs {
|
||||
if !mediaLooksEpisodicForGrouping(item) {
|
||||
return mediaSeriesKeyInputs{}
|
||||
}
|
||||
return mediaSeriesKeyInputs{
|
||||
episodic: true,
|
||||
pathKey: mediaSeriesRawKey(item),
|
||||
externalKey: repeatedSeriesExternalKey(item),
|
||||
titleKey: repeatedSeriesTitleKey(item),
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeMediaSeriesKeys(items []model.Media) []mediaSeriesKeyInputs {
|
||||
inputs := make([]mediaSeriesKeyInputs, len(items))
|
||||
for i := range items {
|
||||
inputs[i] = analyzeMediaSeriesKey(items[i])
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
return newMediaSeriesKeyResolverFromInputs(analyzeMediaSeriesKeys(items))
|
||||
}
|
||||
|
||||
func newMediaSeriesKeyResolverFromInputs(inputs []mediaSeriesKeyInputs) mediaSeriesKeyResolver {
|
||||
resolver := mediaSeriesKeyResolver{
|
||||
pathCounts: make(map[string]int),
|
||||
externalCounts: make(map[string]int),
|
||||
titleCounts: make(map[string]int),
|
||||
pathTitles: make(map[string]string),
|
||||
}
|
||||
for _, item := range items {
|
||||
if !mediaLooksEpisodicForGrouping(item) {
|
||||
pathTitleCandidates := make(map[string]map[string]struct{})
|
||||
for _, input := range inputs {
|
||||
if !input.episodic {
|
||||
continue
|
||||
}
|
||||
if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") {
|
||||
resolver.pathCounts[key]++
|
||||
if strings.HasPrefix(input.pathKey, "library-path") {
|
||||
resolver.pathCounts[input.pathKey]++
|
||||
if input.titleKey != "" {
|
||||
if pathTitleCandidates[input.pathKey] == nil {
|
||||
pathTitleCandidates[input.pathKey] = make(map[string]struct{})
|
||||
}
|
||||
pathTitleCandidates[input.pathKey][input.titleKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
if key := repeatedSeriesExternalKey(item); key != "" {
|
||||
resolver.externalCounts[key]++
|
||||
if input.externalKey != "" {
|
||||
resolver.externalCounts[input.externalKey]++
|
||||
}
|
||||
if key := repeatedSeriesTitleKey(item); key != "" {
|
||||
resolver.titleCounts[key]++
|
||||
if input.titleKey != "" {
|
||||
resolver.titleCounts[input.titleKey]++
|
||||
}
|
||||
}
|
||||
// A scraper can normalize the same show to one title while the source
|
||||
// release folders still contain different tags (1080p/2160p, uploader
|
||||
// names, etc.). Remember an unambiguous title alias for each path group so
|
||||
// those folders are bridged instead of rendered as separate collections.
|
||||
pathTitleCandidates := make(map[string]map[string]struct{})
|
||||
for _, item := range items {
|
||||
if !mediaLooksEpisodicForGrouping(item) {
|
||||
continue
|
||||
}
|
||||
pathKey := mediaSeriesRawKey(item)
|
||||
titleKey := repeatedSeriesTitleKey(item)
|
||||
if !strings.HasPrefix(pathKey, "library-path") || titleKey == "" || resolver.titleCounts[titleKey] < 2 {
|
||||
continue
|
||||
}
|
||||
if pathTitleCandidates[pathKey] == nil {
|
||||
pathTitleCandidates[pathKey] = make(map[string]struct{})
|
||||
}
|
||||
pathTitleCandidates[pathKey][titleKey] = struct{}{}
|
||||
}
|
||||
for pathKey, candidates := range pathTitleCandidates {
|
||||
if len(candidates) != 1 {
|
||||
continue
|
||||
}
|
||||
repeatedTitle := ""
|
||||
repeatedCount := 0
|
||||
for titleKey := range candidates {
|
||||
resolver.pathTitles[pathKey] = titleKey
|
||||
if resolver.titleCounts[titleKey] < 2 {
|
||||
continue
|
||||
}
|
||||
repeatedTitle = titleKey
|
||||
repeatedCount++
|
||||
}
|
||||
if repeatedCount == 1 {
|
||||
resolver.pathTitles[pathKey] = repeatedTitle
|
||||
}
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
// resolveMediaSeriesKeys 对输入只做一轮派生字段计算,返回可供后续 O(1) 解析的
|
||||
// resolver 以及每条记录对应的 key。这是整库剧集加载的快速路径。
|
||||
func resolveMediaSeriesKeys(items []model.Media) (mediaSeriesKeyResolver, []string) {
|
||||
inputs := analyzeMediaSeriesKeys(items)
|
||||
resolver := newMediaSeriesKeyResolverFromInputs(inputs)
|
||||
keys := make([]string, len(items))
|
||||
for i := range items {
|
||||
keys[i] = resolver.keyFromInputs(items[i], inputs[i])
|
||||
}
|
||||
return resolver, keys
|
||||
}
|
||||
|
||||
func (r mediaSeriesKeyResolver) key(media model.Media) string {
|
||||
if mediaLooksEpisodicForGrouping(media) {
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
|
||||
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
|
||||
return r.keyFromInputs(media, analyzeMediaSeriesKey(media))
|
||||
}
|
||||
|
||||
func (r mediaSeriesKeyResolver) keyFromInputs(media model.Media, input mediaSeriesKeyInputs) string {
|
||||
if input.episodic {
|
||||
if strings.HasPrefix(input.pathKey, "library-path") {
|
||||
if titleKey := r.pathTitles[input.pathKey]; titleKey != "" {
|
||||
return compactSeriesKey(titleKey)
|
||||
}
|
||||
if r.pathCounts[pathKey] > 1 {
|
||||
return compactSeriesKey(pathKey)
|
||||
// A series directory is the strongest identity for mixed rows:
|
||||
// main episodes and specials (CM/NCOP/PV/OVA) may be scraped to
|
||||
// slightly different titles, but they still belong to one show.
|
||||
if r.pathCounts[input.pathKey] > 1 {
|
||||
return compactSeriesKey(input.pathKey)
|
||||
}
|
||||
}
|
||||
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
if input.titleKey != "" && r.titleCounts[input.titleKey] > 1 {
|
||||
return compactSeriesKey(input.titleKey)
|
||||
}
|
||||
if input.externalKey != "" && r.externalCounts[input.externalKey] > 1 {
|
||||
return compactSeriesKey(input.externalKey)
|
||||
}
|
||||
}
|
||||
return mediaSeriesKey(media)
|
||||
|
||||
@@ -47,14 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("recent cards = %#v, want one series card", cards)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||||
main := model.Media{
|
||||
@@ -377,6 +377,51 @@ func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMixedTitlesInSameEpisodicDirectoryTogether(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "main-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][01][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "main-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][02][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][CM_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][PV_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards=%#v, want main episodes and specials in the same directory folded into one card", cards)
|
||||
}
|
||||
if cards[0].Count != 4 {
|
||||
t.Fatalf("series count=%d, want 4 items", cards[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
@@ -410,15 +455,19 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/episode.jpg",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
theatrical := model.Media{
|
||||
Base: model.Base{ID: "theatrical"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△ 剧场版",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
TMDbID: 566466,
|
||||
Base: model.Base{ID: "theatrical"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△ 剧场版",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg",
|
||||
BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg",
|
||||
Overview: "剧场版简介",
|
||||
TMDbID: 566466,
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
||||
@@ -428,6 +477,12 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||||
if cards[0].Count != 2 {
|
||||
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
|
||||
}
|
||||
if cards[0].Rep.ID != episode.ID {
|
||||
t.Fatalf("series representative=%q, want TV episode %q", cards[0].Rep.ID, episode.ID)
|
||||
}
|
||||
if cards[0].Rep.Title != episode.Title {
|
||||
t.Fatalf("series representative title=%q, want %q", cards[0].Rep.Title, episode.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
|
||||
@@ -493,11 +548,11 @@ func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
@@ -538,3 +593,47 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
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)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1},
|
||||
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2},
|
||||
}
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
visibility := MediaVisibility{IncludeNSFW: true}
|
||||
cards, total, err := svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
|
||||
t.Fatalf("cold series cards = %#v, total = %d; want one two-episode card", cards, total)
|
||||
}
|
||||
|
||||
cachedObj, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, visibility))
|
||||
if !ok {
|
||||
t.Fatal("expected full library rows and precomputed cards to be cached")
|
||||
}
|
||||
cached, ok := cachedObj.(*libraryRowsCacheValue)
|
||||
if !ok || cached.Cards == nil || len(cached.Cards) != 1 {
|
||||
t.Fatalf("cached rows value = %#v, want one precomputed card", cachedObj)
|
||||
}
|
||||
|
||||
cards, total, err = svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
|
||||
t.Fatalf("warm series cards = %#v, total = %d; want cached result", cards, total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaSortSpec is the normalized sort request shared by media and series endpoints.
|
||||
type MediaSortSpec struct {
|
||||
Field string
|
||||
Order string
|
||||
}
|
||||
|
||||
// NormalizeMediaSort validates the request against the same fields the web UI
|
||||
// exposes. Unknown values fall back to the historical release-date ordering.
|
||||
func NormalizeMediaSort(field, order string) MediaSortSpec {
|
||||
field = strings.ToLower(strings.TrimSpace(field))
|
||||
switch field {
|
||||
case "title", "release_date", "year", "created_at", "updated_at", "rating", "duration", "bitrate", "last_played", "random":
|
||||
case "imdb_rating":
|
||||
field = "rating"
|
||||
default:
|
||||
field = "release_date"
|
||||
}
|
||||
|
||||
order = strings.ToLower(strings.TrimSpace(order))
|
||||
if order != "asc" && order != "desc" {
|
||||
if field == "title" {
|
||||
order = "asc"
|
||||
} else {
|
||||
order = "desc"
|
||||
}
|
||||
}
|
||||
if field == "random" {
|
||||
// The web client shuffles the complete result so pagination stays stable.
|
||||
field = "release_date"
|
||||
order = "desc"
|
||||
}
|
||||
return MediaSortSpec{Field: field, Order: order}
|
||||
}
|
||||
|
||||
// SortMediaItems returns a sorted copy of the grouped media list. The input is
|
||||
// often backed by an immutable runtime cache, so callers must not rely on the
|
||||
// returned slice aliasing it.
|
||||
func SortMediaItems(items []MediaItem, field, order string, history map[string]time.Time) []MediaItem {
|
||||
spec := NormalizeMediaSort(field, order)
|
||||
out := append([]MediaItem(nil), items...)
|
||||
if len(out) < 2 {
|
||||
return out
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
cmp := compareMediaByField(out[i], out[j], spec, history)
|
||||
if cmp == 0 {
|
||||
cmp = compareID(out[i].ID, out[j].ID)
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SortSeriesCards returns a sorted copy of series cards.
|
||||
func SortSeriesCards(cards []SeriesCard, field, order string, history map[string]time.Time) []SeriesCard {
|
||||
spec := NormalizeMediaSort(field, order)
|
||||
out := append([]SeriesCard(nil), cards...)
|
||||
if len(out) < 2 {
|
||||
return out
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
cmp := compareSeriesByField(out[i], out[j], spec, history)
|
||||
if cmp == 0 {
|
||||
cmp = compareID(out[i].Key, out[j].Key)
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func compareMediaByField(a, b MediaItem, spec MediaSortSpec, history map[string]time.Time) int {
|
||||
switch spec.Field {
|
||||
case "title":
|
||||
ta, tb := mediaSortTitle(a.Media), mediaSortTitle(b.Media)
|
||||
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(ta)), strings.ToLower(strings.TrimSpace(tb))), strings.TrimSpace(ta) != "", strings.TrimSpace(tb) != "", spec.Order)
|
||||
case "release_date":
|
||||
ta, oka := mediaReleaseTime(a.Media)
|
||||
tb, okb := mediaReleaseTime(b.Media)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
case "year":
|
||||
return compareOrdered(compareInt(a.Year, b.Year), true, true, spec.Order)
|
||||
case "created_at":
|
||||
return compareOrdered(compareTime(a.CreatedAt, b.CreatedAt), !a.CreatedAt.IsZero(), !b.CreatedAt.IsZero(), spec.Order)
|
||||
case "updated_at":
|
||||
ta, oka := mediaUpdatedTime(a.Media)
|
||||
tb, okb := mediaUpdatedTime(b.Media)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
case "rating":
|
||||
return compareOrdered(compareFloat(float64(a.Rating), float64(b.Rating)), true, true, spec.Order)
|
||||
case "duration":
|
||||
return compareOrdered(compareInt(a.DurationSec, b.DurationSec), true, true, spec.Order)
|
||||
case "bitrate":
|
||||
return compareOrdered(compareInt64(a.SizeBytes, b.SizeBytes), true, true, spec.Order)
|
||||
case "last_played":
|
||||
ta, oka := mediaItemLastPlayedTime(a, history)
|
||||
tb, okb := mediaItemLastPlayedTime(b, history)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
func compareSeriesByField(a, b SeriesCard, spec MediaSortSpec, history map[string]time.Time) int {
|
||||
switch spec.Field {
|
||||
case "title":
|
||||
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(mediaSortTitle(a.Rep))), strings.ToLower(strings.TrimSpace(mediaSortTitle(b.Rep)))), strings.TrimSpace(mediaSortTitle(a.Rep)) != "", strings.TrimSpace(mediaSortTitle(b.Rep)) != "", spec.Order)
|
||||
case "release_date":
|
||||
ta, oka := mediaReleaseTime(a.Rep)
|
||||
tb, okb := mediaReleaseTime(b.Rep)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
case "year":
|
||||
return compareOrdered(compareInt(a.Rep.Year, b.Rep.Year), true, true, spec.Order)
|
||||
case "created_at":
|
||||
return compareOrdered(compareTime(a.Rep.CreatedAt, b.Rep.CreatedAt), !a.Rep.CreatedAt.IsZero(), !b.Rep.CreatedAt.IsZero(), spec.Order)
|
||||
case "updated_at":
|
||||
ta, oka := seriesUpdatedTime(a)
|
||||
tb, okb := seriesUpdatedTime(b)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
case "rating":
|
||||
return compareOrdered(compareFloat(float64(a.Rep.Rating), float64(b.Rep.Rating)), true, true, spec.Order)
|
||||
case "duration":
|
||||
return compareOrdered(compareInt(a.Rep.DurationSec, b.Rep.DurationSec), true, true, spec.Order)
|
||||
case "bitrate":
|
||||
return compareOrdered(compareInt64(a.Rep.SizeBytes, b.Rep.SizeBytes), true, true, spec.Order)
|
||||
case "last_played":
|
||||
ta, oka := seriesLastPlayed(a, history)
|
||||
tb, okb := seriesLastPlayed(b, history)
|
||||
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mediaSortTitle(m model.Media) string {
|
||||
if strings.TrimSpace(m.Title) != "" {
|
||||
return m.Title
|
||||
}
|
||||
return m.OriginalName
|
||||
}
|
||||
|
||||
func mediaReleaseTime(m model.Media) (time.Time, bool) {
|
||||
if raw := strings.TrimSpace(m.ReleaseDate); raw != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
if m.Year > 0 {
|
||||
return time.Date(m.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func mediaUpdatedTime(m model.Media) (time.Time, bool) {
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
return m.UpdatedAt, true
|
||||
}
|
||||
if !m.CreatedAt.IsZero() {
|
||||
return m.CreatedAt, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
|
||||
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
|
||||
return *card.LastAddedAt, true
|
||||
}
|
||||
return mediaUpdatedTime(card.Rep)
|
||||
}
|
||||
|
||||
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
|
||||
for _, id := range []string{card.Rep.ID, card.LinkMedia.ID} {
|
||||
if played, ok := history[id]; ok {
|
||||
return played, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func mediaItemLastPlayedTime(item MediaItem, history map[string]time.Time) (time.Time, bool) {
|
||||
best, found := history[item.ID]
|
||||
for _, version := range item.Versions {
|
||||
if played, ok := history[version.ID]; ok && (!found || played.After(best)) {
|
||||
best = played
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
func compareOrdered(cmp int, aValid, bValid bool, order string) int {
|
||||
if !aValid && !bValid {
|
||||
return 0
|
||||
}
|
||||
if !aValid {
|
||||
return 1
|
||||
}
|
||||
if !bValid {
|
||||
return -1
|
||||
}
|
||||
if order == "desc" {
|
||||
return -cmp
|
||||
}
|
||||
return cmp
|
||||
}
|
||||
|
||||
func compareTime(a, b time.Time) int {
|
||||
if a.Before(b) {
|
||||
return -1
|
||||
}
|
||||
if a.After(b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareInt(a, b int) int {
|
||||
if a < b {
|
||||
return -1
|
||||
}
|
||||
if a > b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareInt64(a, b int64) int {
|
||||
if a < b {
|
||||
return -1
|
||||
}
|
||||
if a > b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareFloat(a, b float64) int {
|
||||
if a < b {
|
||||
return -1
|
||||
}
|
||||
if a > b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareID(a, b string) int {
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestSortMediaItemsTitleAscEmptyLast(t *testing.T) {
|
||||
items := []MediaItem{
|
||||
{Media: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
|
||||
{Media: model.Media{Base: model.Base{ID: "empty"}}},
|
||||
{Media: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
|
||||
}
|
||||
got := SortMediaItems(items, "title", "asc", nil)
|
||||
if got[0].ID != "a" || got[1].ID != "b" || got[2].ID != "empty" {
|
||||
t.Fatalf("title order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortMediaItemsReleaseDescEmptyLast(t *testing.T) {
|
||||
items := []MediaItem{
|
||||
{Media: model.Media{Base: model.Base{ID: "old"}, ReleaseDate: "2020-01-01"}},
|
||||
{Media: model.Media{Base: model.Base{ID: "empty"}}},
|
||||
{Media: model.Media{Base: model.Base{ID: "new"}, ReleaseDate: "2024-01-01"}},
|
||||
}
|
||||
got := SortMediaItems(items, "release_date", "desc", nil)
|
||||
if got[0].ID != "new" || got[1].ID != "old" || got[2].ID != "empty" {
|
||||
t.Fatalf("release order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortSeriesCardsTitleAsc(t *testing.T) {
|
||||
cards := []SeriesCard{
|
||||
{Key: "b", Rep: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
|
||||
{Key: "a", Rep: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
|
||||
}
|
||||
got := SortSeriesCards(cards, "title", "asc", nil)
|
||||
if got[0].Key != "a" || got[1].Key != "b" {
|
||||
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -114,6 +115,41 @@ func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsKeepsBracketNumberedEpisodesSeparate(t *testing.T) {
|
||||
// UHA-WINGS 命名:[组名][标题][01][BDRIP 1920x1080 ...].strm。
|
||||
// 曾因 1920x1080 被解析成 S20E108,12 集全部折叠成一集的多个版本。
|
||||
rows := make([]model.Media, 0, 12)
|
||||
for episode := 1; episode <= 12; episode++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("ep-%02d", episode)},
|
||||
LibraryID: "anime",
|
||||
Title: "彼得·格里尔的贤者时间",
|
||||
Path: fmt.Sprintf(
|
||||
"/media/影视库/动漫/彼得·格里尔的贤者时间/[UHA-WINGS][Peter Grill to Kenja no Jikan][%02d][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm",
|
||||
episode),
|
||||
TMDbID: 99080,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
season, episode := ParseEpisode(rows[i].Path)
|
||||
rows[i].SeasonNum = season
|
||||
rows[i].EpisodeNum = episode
|
||||
}
|
||||
|
||||
grouped := groupMediaVersions(rows)
|
||||
if len(grouped) != 12 {
|
||||
t.Fatalf("grouped len = %d, want 12 distinct episodes: %#v", len(grouped), grouped)
|
||||
}
|
||||
for _, item := range grouped {
|
||||
if len(item.Versions) > 1 {
|
||||
t.Fatalf("distinct episodes were folded into versions: %#v", item.Versions)
|
||||
}
|
||||
if item.SeasonNum != 1 || item.EpisodeNum < 1 || item.EpisodeNum > 12 {
|
||||
t.Fatalf("unexpected episode identity s=%d e=%d", item.SeasonNum, item.EpisodeNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{
|
||||
@@ -245,7 +281,8 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -259,6 +296,17 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
|
||||
if page[0].Media.Path != rows[0].Path {
|
||||
t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path)
|
||||
}
|
||||
cacheKey := svc.groupedItemsCacheKey(lib.ID, []string{lib.ID}, repository.MediaQueryFilter{IncludeNSFW: true})
|
||||
if _, ok := svc.cache.GetObject(cacheKey); !ok {
|
||||
t.Fatal("expected grouped media items to be cached after the first page request")
|
||||
}
|
||||
secondPage, secondTotal, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 2, 1, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondTotal != total || len(secondPage) != 1 || secondPage[0].Media.Path != rows[2].Path {
|
||||
t.Fatalf("cached second page = %#v, total = %d; want Matrix item and total %d", secondPage, secondTotal, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,7 +55,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
continue
|
||||
}
|
||||
}
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year)
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year, false)
|
||||
if match != nil && strings.TrimSpace(match.Title) != "" {
|
||||
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
||||
if cache != nil {
|
||||
|
||||
@@ -43,7 +43,7 @@ var artworkSidecarSuffixes = []string{
|
||||
// artworkSidecarExtensions are the image extensions a sidecar may use. Both
|
||||
// "base-poster.jpg" (bare separator) and "base.poster.jpg" (dotted separator)
|
||||
// rely on suffix matching, so we probe the common image extensions.
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"}
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"}
|
||||
|
||||
// transferSidecarArtwork moves/copies/links the scraped poster/backdrop
|
||||
// sidecar files alongside its media using the same transfer mode, mirroring
|
||||
@@ -84,6 +84,7 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
srcAdultCode := AdultCodeFromMediaPath(srcMedia)
|
||||
for _, src := range sources {
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
@@ -97,7 +98,10 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
}
|
||||
}
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
if srcAdultCode != "" && strings.HasPrefix(strings.ToLower(name), strings.ToLower(srcAdultCode)) {
|
||||
// Adult sidecars are keyed by code (e.g. ADN-188-poster.jpg) and should
|
||||
// follow the media without being renamed to the display title stem.
|
||||
} else if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
|
||||
@@ -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.
|
||||
@@ -227,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListFavouriteIDs returns the user's favourite media IDs without hydrating
|
||||
// remote Emby items. Heart-icon state only needs the IDs; fetching each remote
|
||||
// detail on every library open adds a few hundred milliseconds.
|
||||
func (p *PlaybackService) ListFavouriteIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if fav.MediaID != "" {
|
||||
ids = append(ids, fav.MediaID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ─── Playlists ──────────────────────────────────────────────────────────────
|
||||
|
||||
// CreatePlaylist persists a new playlist owned by userID.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user