mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -47,12 +47,11 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 2000)
|
||||
v.SetDefault("cache.images_originals_max_size_mb", 256)
|
||||
v.SetDefault("cache.images_originals_ttl_hours", 168)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 900)
|
||||
v.SetDefault("cache.reader_content_max_size_mb", 1024)
|
||||
v.SetDefault("cache.reader_content_ttl_hours", 168)
|
||||
v.SetDefault("cache.reader_files_ttl_hours", 720)
|
||||
|
||||
@@ -50,9 +50,6 @@ func (c *Config) normalize() error {
|
||||
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
|
||||
c.Cache.ImagesOriginalsMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.ImagesOriginalsTTLHours < 0 {
|
||||
c.Cache.ImagesOriginalsTTLHours = 0
|
||||
}
|
||||
if c.Cache.MemoryMaxSizeMB <= 0 {
|
||||
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
|
||||
}
|
||||
|
||||
@@ -125,9 +125,6 @@ type CacheConfig struct {
|
||||
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
|
||||
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
|
||||
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
|
||||
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
|
||||
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
|
||||
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
|
||||
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
|
||||
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
@@ -136,7 +133,8 @@ type CacheConfig struct {
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
// 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建,
|
||||
// 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
|
||||
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
|
||||
|
||||
@@ -376,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||
`DROP INDEX IF EXISTS idx_media_library_release_active`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
// 虚拟剧集分组索引:series_id 为空的剧集行要按
|
||||
// (season_num, episode_num, episode_fraction, created_at) 排序后做内存
|
||||
// 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的
|
||||
// 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||
|
||||
@@ -87,7 +87,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||
svc.Transcoder.StopAll()
|
||||
}
|
||||
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
|
||||
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
|
||||
if svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
}
|
||||
|
||||
@@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
// 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder
|
||||
// image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。
|
||||
if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") {
|
||||
if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil &&
|
||||
strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 {
|
||||
width, height := libraryCoverRequestSize(c)
|
||||
if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||||
c.Header("Content-Type", "image/jpeg")
|
||||
c.Header("Content-Length", strconv.Itoa(len(data)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/jpeg", data)
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。
|
||||
//
|
||||
// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张
|
||||
// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成
|
||||
// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App
|
||||
// 都指向这一份,外观统一。
|
||||
func libraryCoverHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" || svc == nil || svc.ImageProxy == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id)
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(lib.CoverURL) != "" {
|
||||
// 管理员自定义封面优先,保持既有行为(代理该地址)。
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"})
|
||||
return
|
||||
}
|
||||
width, height := libraryCoverRequestSize(c)
|
||||
data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height)
|
||||
if err != nil || len(data) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"})
|
||||
return
|
||||
}
|
||||
writeLibraryCoverImage(c, data, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用
|
||||
// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby
|
||||
// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的
|
||||
// 边界使用。
|
||||
func libraryCoverRequestSize(c *gin.Context) (int, int) {
|
||||
return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"),
|
||||
firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h")
|
||||
}
|
||||
|
||||
func firstPositiveQueryIntParam(c *gin.Context, names ...string) int {
|
||||
for _, name := range names {
|
||||
raw := strings.TrimSpace(c.Query(name))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func writeLibraryCoverImage(c *gin.Context, data []byte, status int) {
|
||||
sum := sha256.Sum256(data)
|
||||
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||||
if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "image/jpeg")
|
||||
c.Header("Content-Length", strconv.Itoa(len(data)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(status)
|
||||
return
|
||||
}
|
||||
c.Data(status, "image/jpeg", data)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func coverTestToken(t *testing.T, secret, userID string) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
|
||||
UserID: userID,
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Subject: userID,
|
||||
},
|
||||
})
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register
|
||||
// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired +
|
||||
// activeUserRequired)不会把它挡成 404。
|
||||
func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const secret = "test-secret"
|
||||
|
||||
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(poster)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
parsed, _ := url.Parse(upstream.URL)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
row := model.Media{
|
||||
Base: model.Base{ID: "m" + string(rune('a'+i))},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Movie " + string(rune('A'+i)),
|
||||
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
|
||||
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")},
|
||||
Secrets: config.SecretsConfig{JWTSecret: secret},
|
||||
}
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
|
||||
svc := &service.Container{Repo: repos, ImageProxy: proxy}
|
||||
|
||||
engine := gin.New()
|
||||
Register(engine, cfg, zap.NewNop(), svc)
|
||||
|
||||
token := coverTestToken(t, secret, user.ID)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ct)
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode cover: %v", err)
|
||||
}
|
||||
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
|
||||
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
func coverTestJPEG(t *testing.T, c color.RGBA) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
|
||||
for y := 0; y < 240; y++ {
|
||||
for x := 0; x < 160; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自
|
||||
// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。
|
||||
func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(poster)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
|
||||
parsed, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse upstream: %v", err)
|
||||
}
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
|
||||
|
||||
svc := &service.Container{Repo: repos, ImageProxy: proxy}
|
||||
router := gin.New()
|
||||
router.GET("/libraries/:id/cover", libraryCoverHandler(svc))
|
||||
router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
|
||||
return router, repos, upstream
|
||||
}
|
||||
|
||||
func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library {
|
||||
t.Helper()
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
row := model.Media{
|
||||
Base: model.Base{ID: "m" + string(rune('a'+i))},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Movie " + string(rune('A'+i)),
|
||||
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
|
||||
PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg",
|
||||
}
|
||||
if err := repos.DB.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
return lib
|
||||
}
|
||||
|
||||
// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口:
|
||||
// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。
|
||||
func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) {
|
||||
router, repos, upstream := newLibraryCoverTestRouter(t)
|
||||
lib := seedCoverLibrary(t, repos, upstream.URL, "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ct)
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode cover: %v", err)
|
||||
}
|
||||
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
|
||||
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
|
||||
}
|
||||
if etag := w.Header().Get("ETag"); etag == "" {
|
||||
t.Fatal("expected an ETag so clients can revalidate")
|
||||
}
|
||||
|
||||
// 条件请求应拿到 304。
|
||||
req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
|
||||
req.Header.Set("If-None-Match", w.Header().Get("ETag"))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Fatalf("conditional request status = %d, want 304", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) {
|
||||
router, _, _ := newLibraryCoverTestRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续
|
||||
// 用自定义封面,不能在不知不觉中换成拼图。
|
||||
func TestLibraryCoverHandlerServesCustomCover(t *testing.T) {
|
||||
router, repos, upstream := newLibraryCoverTestRouter(t)
|
||||
lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil {
|
||||
t.Fatalf("custom cover should be an image: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时
|
||||
// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。
|
||||
func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(poster)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
parsed, _ := url.Parse(upstream.URL)
|
||||
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
row := model.Media{
|
||||
Base: model.Base{ID: "e" + string(rune('a'+i))},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Show " + string(rune('A'+i)),
|
||||
Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1,
|
||||
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
|
||||
}
|
||||
if err := repos.DB.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: proxy,
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode emby library cover: %v", err)
|
||||
}
|
||||
// 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。
|
||||
if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 {
|
||||
t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -40,6 +41,25 @@ type webLibraryPayload struct {
|
||||
RemoteSource string `json:"remote_source,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||
// CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的
|
||||
// cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都
|
||||
// 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。
|
||||
CoverImageURL string `json:"cover_image_url,omitempty"`
|
||||
}
|
||||
|
||||
// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游
|
||||
// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。
|
||||
func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string {
|
||||
if isRemote {
|
||||
return ""
|
||||
}
|
||||
if raw := strings.TrimSpace(lib.CoverURL); raw != "" {
|
||||
return raw
|
||||
}
|
||||
if strings.TrimSpace(lib.ID) == "" {
|
||||
return ""
|
||||
}
|
||||
return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover"
|
||||
}
|
||||
|
||||
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
|
||||
@@ -124,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{
|
||||
Library: p.Library,
|
||||
Total: p.Total,
|
||||
Cards: service.NewSeriesCardViews(p.Cards),
|
||||
Library: p.Library,
|
||||
Total: p.Total,
|
||||
Cards: service.NewSeriesCardViews(p.Cards),
|
||||
CoverImageURL: effectiveLibraryCoverURL(p.Library, false),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -145,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)})
|
||||
}
|
||||
}
|
||||
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
|
||||
@@ -272,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
|
||||
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
|
||||
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||
// 服务端生成的媒体库封面(成员海报拼图),Web / App / Emby 共用同一份。
|
||||
authed.GET("/libraries/:id/cover", libraryCoverHandler(svc))
|
||||
authed.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
|
||||
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
|
||||
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
|
||||
@@ -74,22 +74,20 @@ type ImageCacheScope struct {
|
||||
DropUnknownResizeVariants bool
|
||||
}
|
||||
|
||||
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。
|
||||
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额。
|
||||
//
|
||||
// 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料,
|
||||
// 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。
|
||||
// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。
|
||||
// 因此原图配小配额,总量超限时也优先淘汰原图。
|
||||
//
|
||||
// 淘汰只看占用(配额头寸),不按文件年龄删除:缩略图生成成本高,只要磁盘还
|
||||
// 放得下就该留着,让再次浏览直接命中缓存。
|
||||
type ImageCachePool struct {
|
||||
Name string
|
||||
Scopes []ImageCacheScope
|
||||
MaxBytes int64 // 0 = 不单独限制
|
||||
MaxAge time.Duration // 0 = 不按时间淘汰
|
||||
MaxBytes int64 // 0 = 不单独限制
|
||||
}
|
||||
|
||||
// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试,
|
||||
// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。
|
||||
const imageFailMarkerMaxAge = 24 * time.Hour
|
||||
|
||||
// tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被
|
||||
// 并发写入,清理时必须跳过。
|
||||
func tempImageCacheFile(name string) bool {
|
||||
@@ -182,12 +180,14 @@ func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheRes
|
||||
_ = os.Remove(entry.path + ".fail")
|
||||
}
|
||||
|
||||
// PruneImageCachePools 按池清理图片缓存:
|
||||
// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰);
|
||||
// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
|
||||
// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
|
||||
// PruneImageCachePools 按池清理图片缓存,只做占用淘汰:
|
||||
// 1. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
|
||||
// 2. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
|
||||
// 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。
|
||||
//
|
||||
// 不按文件年龄删除:过期即删会让一张几天前看过的海报重新解码(2 核机器上
|
||||
// 每张约 0.2 秒),代价远高于占着那几十 KB 磁盘。
|
||||
//
|
||||
// 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。
|
||||
func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) {
|
||||
var result PruneImageCacheResult
|
||||
@@ -207,16 +207,18 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
|
||||
}
|
||||
result.RemainingBytes = result.TotalBytesBefore
|
||||
|
||||
// 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。
|
||||
cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge)
|
||||
// 失败标记不计入容量:只清掉对应缓存文件已经不存在的孤儿标记(它们只是
|
||||
// 观测/重试用的噪音,留着没有任何作用)。
|
||||
for _, marker := range failMarkers {
|
||||
if marker.modTime.Before(cutoffMarkers) {
|
||||
_ = os.Remove(marker.path)
|
||||
base := strings.TrimSuffix(marker.path, ".fail")
|
||||
if _, err := os.Stat(base); err == nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(marker.path)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
deleted := make(map[string]bool, len(entries))
|
||||
|
||||
deleteEntry := func(entry imageCacheFileEntry) {
|
||||
if deleted[entry.path] {
|
||||
return
|
||||
@@ -225,20 +227,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
|
||||
removeImageCacheEntry(entry, &result)
|
||||
}
|
||||
|
||||
// 1. 按池保留时长淘汰(原图池)。
|
||||
for i := range pools {
|
||||
if pools[i].MaxAge <= 0 {
|
||||
continue
|
||||
}
|
||||
cutoff := now.Add(-pools[i].MaxAge)
|
||||
for _, entry := range entries {
|
||||
if entry.pool == i && entry.modTime.Before(cutoff) {
|
||||
deleteEntry(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 池自身配额。
|
||||
// 1. 池自身配额。
|
||||
for i := range pools {
|
||||
maxBytes := pools[i].MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
@@ -263,7 +252,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
|
||||
// 2. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
|
||||
if totalBytes > 0 && result.RemainingBytes > totalBytes {
|
||||
target := totalBytes * 80 / 100
|
||||
for _, entry := range evictionOrder(entries, deleted) {
|
||||
@@ -357,11 +346,10 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
|
||||
// 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被
|
||||
// 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。
|
||||
// 池顺序即总量超限时的淘汰优先级。
|
||||
func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool {
|
||||
func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool {
|
||||
originals := ImageCachePool{
|
||||
Name: "originals",
|
||||
MaxBytes: originalsMaxBytes,
|
||||
MaxAge: originalsMaxAge,
|
||||
Scopes: []ImageCacheScope{
|
||||
{Root: imagesDir, Recursive: false}, // 旧版平铺布局
|
||||
{Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true},
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// writePoolFile ?????????????????????????
|
||||
// writePoolFile ??????????????? mtime ???????
|
||||
func writePoolFile(t *testing.T, path string, size int, modTime time.Time) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
@@ -39,42 +39,38 @@ func mustExist(t *testing.T, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// currentVariantPath ????????????????.<??x<??q<??>.img??
|
||||
// ??????????????????????????
|
||||
// currentVariantPath ???????????????????
|
||||
// <64 ????>.<?>x<?>q<??>.img
|
||||
func currentVariantPath(dir, keyChar string, width, height, quality int) string {
|
||||
key := strings.Repeat(keyChar, sha256HexLength)
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.%dx%dq%d.img", key, width, height, quality))
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsOriginalTTL ????????????????????
|
||||
// ?? TTL ??????????????????????
|
||||
func TestPruneImageCachePoolsOriginalTTL(t *testing.T) {
|
||||
// TestPruneImageCachePoolsKeepsOldFilesUnderQuota ????????????
|
||||
// ???????????????????????? / TTL ????
|
||||
func TestPruneImageCachePoolsKeepsOldFilesUnderQuota(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
stale := filepath.Join(imagesDir, imageOriginalCacheSubdir, "stale-original")
|
||||
fresh := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh-original")
|
||||
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
|
||||
writePoolFile(t, stale, 400, now.Add(-8*24*time.Hour))
|
||||
writePoolFile(t, fresh, 400, now.Add(-1*time.Hour))
|
||||
// ??????????TTL ????
|
||||
writePoolFile(t, thumb, 400, now.Add(-8*24*time.Hour))
|
||||
oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old-original")
|
||||
oldThumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
|
||||
writePoolFile(t, oldOriginal, 400, now.Add(-90*24*time.Hour))
|
||||
writePoolFile(t, oldThumb, 400, now.Add(-90*24*time.Hour))
|
||||
|
||||
pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour)
|
||||
pools := ImageCachePools(imagesDir, 0)
|
||||
res, err := PruneImageCachePools(pools, 100*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 1 {
|
||||
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
|
||||
if res.DeletedFiles != 0 {
|
||||
t.Fatalf("deleted files = %d, want 0 (age must not trigger deletion)", res.DeletedFiles)
|
||||
}
|
||||
mustNotExist(t, stale)
|
||||
mustExist(t, fresh)
|
||||
mustExist(t, thumb)
|
||||
mustExist(t, oldOriginal)
|
||||
mustExist(t, oldThumb)
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ????????????
|
||||
// ????????????????????
|
||||
// ????????????????
|
||||
func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
now := time.Now()
|
||||
@@ -82,13 +78,13 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
|
||||
original := filepath.Join(imagesDir, imageOriginalCacheSubdir, "original")
|
||||
rendition := filepath.Join(imagesDir, imageRenditionCacheSubdir, "rendition")
|
||||
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
|
||||
// ?????????????????
|
||||
// ?????????????????????????
|
||||
writePoolFile(t, original, 1024*1024, now.Add(-72*time.Hour))
|
||||
writePoolFile(t, rendition, 1024*1024, now.Add(-2*time.Hour))
|
||||
writePoolFile(t, thumb, 1024*1024, now.Add(-1*time.Hour))
|
||||
|
||||
pools := ImageCachePools(imagesDir, 0, 0)
|
||||
// ?? 3MB????2.4MB -> ????? 1.92MB??
|
||||
pools := ImageCachePools(imagesDir, 0)
|
||||
// ?? 3MB??? 2.4MB -> ??? 1.92MB ???
|
||||
res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
@@ -104,8 +100,8 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ????????????
|
||||
// ??????????????
|
||||
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ???????????
|
||||
// ????????????
|
||||
func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
now := time.Now()
|
||||
@@ -117,8 +113,8 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
|
||||
writePoolFile(t, newOriginal, 800, now.Add(-1*time.Hour))
|
||||
writePoolFile(t, thumb, 800, now.Add(-2*time.Hour))
|
||||
|
||||
// ???? 1000 ??????????1600 -> ????800????? 1 ????
|
||||
pools := ImageCachePools(imagesDir, 1000, 0)
|
||||
// ???? 1000??? 1600 ?? -> ??? 800??? 1 ???????
|
||||
pools := ImageCachePools(imagesDir, 1000)
|
||||
res, err := PruneImageCachePools(pools, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
@@ -131,24 +127,26 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
|
||||
mustExist(t, thumb)
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsCleansFailMarkers ????????????????
|
||||
// ????????????????
|
||||
// TestPruneImageCachePoolsCleansFailMarkers ???????????????
|
||||
// ???????????????????????????????????
|
||||
func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry")
|
||||
entryMarker := entry + ".fail"
|
||||
kept := filepath.Join(imagesDir, imageOriginalCacheSubdir, "kept")
|
||||
keptMarker := kept + ".fail"
|
||||
orphanMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "orphan.fail")
|
||||
freshMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh.fail")
|
||||
|
||||
writePoolFile(t, entry, 900, now.Add(-10*time.Hour))
|
||||
writePoolFile(t, entryMarker, 35, now.Add(-10*time.Hour))
|
||||
writePoolFile(t, entry, 900, now.Add(-2*time.Hour))
|
||||
writePoolFile(t, entryMarker, 35, now.Add(-2*time.Hour))
|
||||
writePoolFile(t, kept, 300, now.Add(-1*time.Hour))
|
||||
writePoolFile(t, keptMarker, 35, now.Add(-1*time.Hour))
|
||||
writePoolFile(t, orphanMarker, 35, now.Add(-48*time.Hour))
|
||||
writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute))
|
||||
|
||||
// ???? 500????900 ?????????????????
|
||||
pools := ImageCachePools(imagesDir, 500, 0)
|
||||
// ???? 500??? 900+300=1200 ???? mtime ????? entry ? 400?
|
||||
pools := ImageCachePools(imagesDir, 500)
|
||||
res, err := PruneImageCachePools(pools, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
@@ -158,26 +156,27 @@ func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
|
||||
}
|
||||
mustNotExist(t, entry)
|
||||
mustNotExist(t, entryMarker)
|
||||
mustExist(t, kept)
|
||||
mustExist(t, keptMarker)
|
||||
mustNotExist(t, orphanMarker)
|
||||
mustExist(t, freshMarker)
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsSkipsTemporaryFiles ????????????????
|
||||
// TestPruneImageCachePoolsSkipsTemporaryFiles ??????????????
|
||||
// ?????????????????
|
||||
func TestPruneImageCachePoolsSkipsTemporaryFiles(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
tmp := filepath.Join(imagesDir, imageOriginalCacheSubdir, "img-1234.tmp")
|
||||
writePoolFile(t, tmp, 4096, time.Now().Add(-48*time.Hour))
|
||||
|
||||
pools := ImageCachePools(imagesDir, 1, 0)
|
||||
pools := ImageCachePools(imagesDir, 1)
|
||||
if _, err := PruneImageCachePools(pools, 1); err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
mustExist(t, tmp)
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img????
|
||||
// ?????????????????????????????????????
|
||||
// TestPruneImageCachePoolsDropsLegacyResizeVariants ?????? .img ??
|
||||
// ???????????????????????????
|
||||
func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
resized := filepath.Join(imagesDir, imageResizeCacheSubdir)
|
||||
@@ -188,7 +187,7 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
|
||||
writePoolFile(t, current, 400, time.Now())
|
||||
writePoolFile(t, other, 400, time.Now())
|
||||
|
||||
pools := ImageCachePools(imagesDir, 0, 0)
|
||||
pools := ImageCachePools(imagesDir, 0)
|
||||
res, err := PruneImageCachePools(pools, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
@@ -201,8 +200,8 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ????
|
||||
// ?????????????????
|
||||
// TestPruneImageCachePoolsCountsLegacyFlatLayout ???????images/ ???
|
||||
// ???????????????????????
|
||||
func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
legacy := filepath.Join(imagesDir, "legacy-flat-cache")
|
||||
@@ -210,7 +209,8 @@ func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
|
||||
writePoolFile(t, legacy, 900, time.Now().Add(-30*24*time.Hour))
|
||||
writePoolFile(t, thumb, 900, time.Now().Add(-30*24*time.Hour))
|
||||
|
||||
pools := ImageCachePools(imagesDir, 0, 24*time.Hour)
|
||||
// ???? 500??????????????????????????
|
||||
pools := ImageCachePools(imagesDir, 500)
|
||||
res, err := PruneImageCachePools(pools, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// resetEmbyInvalThrottle 清掉全局失效节流,让测试能断言「这一次调用到底有
|
||||
// 没有真的清缓存」。测试结束后恢复原值,避免影响同包其他用例。
|
||||
func resetEmbyInvalThrottle(t *testing.T) {
|
||||
t.Helper()
|
||||
embyInvalMu.Lock()
|
||||
previous := embyInvalLast
|
||||
embyInvalLast = time.Time{}
|
||||
embyInvalMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
embyInvalMu.Lock()
|
||||
embyInvalLast = previous
|
||||
embyInvalMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newCachedTestEmbyService(t *testing.T) *EmbyService {
|
||||
t.Helper()
|
||||
svc := newTestEmbyService(t)
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
t.Cleanup(func() { _ = cache.Close() })
|
||||
return svc.SetRuntimeCache(cache)
|
||||
}
|
||||
|
||||
// TestRecordProgressKeepsEmbyPayloadCache 覆盖首页加载慢的核心回归点:
|
||||
// 播放期间客户端每 5-10 秒上报一次进度,如果每次都清空 media:emby:*,
|
||||
// 客户端刷新首页时 73 个媒体库的 Latest 会全部回源重建(生产环境实测
|
||||
// 首批 8-25s)。普通进度上报必须保留载荷缓存;只有「已看完」翻转才失效。
|
||||
func TestRecordProgressKeepsEmbyPayloadCache(t *testing.T) {
|
||||
svc := newCachedTestEmbyService(t)
|
||||
const mediaID = "ep-1"
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
Title: "第 1 集",
|
||||
Path: `/media/tv/Show/Season 01/S01E01.mkv`,
|
||||
DurationSec: 100,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
key := svc.embyLatestCacheKey("user-1", "", 16)
|
||||
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
|
||||
Items: []map[string]any{{"Id": "cached-item"}},
|
||||
}, time.Minute, time.Hour)
|
||||
|
||||
resetEmbyInvalThrottle(t)
|
||||
// 60s / 100s:播放中途
|
||||
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 600_000_000, 1_000_000_000, "session-1"); err != nil {
|
||||
t.Fatalf("record progress: %v", err)
|
||||
}
|
||||
if _, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key); !ok || stale {
|
||||
t.Fatal("普通进度上报不应让 Emby 列表载荷缓存失效")
|
||||
}
|
||||
|
||||
resetEmbyInvalThrottle(t)
|
||||
// 98s / 100s:进入片尾 30s 内的「已看完」区间
|
||||
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 980_000_000, 1_000_000_000, "session-2"); err != nil {
|
||||
t.Fatalf("record completed progress: %v", err)
|
||||
}
|
||||
if _, _, ok := svc.cachedLatestItemsWithStale(t.Context(), key); ok {
|
||||
t.Fatal("看完状态翻转后应让 Emby 列表载荷缓存失效")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindSeriesGroupCachesSiblingVirtualSeries 覆盖 N+1 回归点:一次全表
|
||||
// 扫描算出了整库的剧集分组,若只缓存命中的那一部,同一页的 16 张卡片和
|
||||
// 16 张海报会各自重扫一遍 media 表。批量记忆后同批分组都应可命中。
|
||||
func TestFindSeriesGroupCachesSiblingVirtualSeries(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
shows := []string{"Alpha", "Bravo", "Charlie"}
|
||||
rows := make([]model.Media, 0, len(shows)*2)
|
||||
for _, show := range shows {
|
||||
for ep := 1; ep <= 2; ep++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
|
||||
LibraryID: lib.ID,
|
||||
Title: show,
|
||||
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: ep,
|
||||
})
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
seriesIDs := map[string]string{}
|
||||
for i := range rows {
|
||||
if rows[i].EpisodeNum == 1 {
|
||||
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
|
||||
}
|
||||
}
|
||||
target, ok := seriesIDs["Alpha"]
|
||||
if !ok {
|
||||
t.Fatal("missing fixture series")
|
||||
}
|
||||
if _, found, err := svc.findSeriesGroup(t.Context(), target, ""); err != nil || !found {
|
||||
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", target, found, err)
|
||||
}
|
||||
|
||||
for show, id := range seriesIDs {
|
||||
if show == "Alpha" {
|
||||
continue
|
||||
}
|
||||
if _, cached := svc.cachedSeriesGroup(id); !cached {
|
||||
t.Fatalf("同一次扫描算出的 %s 应一并进入缓存,否则每张卡片/海报都会重扫整库", show)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLatestPayloadKeepsStaleCopyForIdleClients 保证「最新添加」载荷在新鲜期
|
||||
// 之后仍有可用的陈旧副本:客户端空闲很久后再打开首页时,先拿陈旧结果并由
|
||||
// 后台重建,而不是让全部媒体库同时回源。
|
||||
func TestLatestPayloadKeepsStaleCopyForIdleClients(t *testing.T) {
|
||||
svc := newCachedTestEmbyService(t)
|
||||
key := svc.embyLatestCacheKey("user-1", "", 16)
|
||||
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
|
||||
Items: []map[string]any{{"Id": "stale-item"}},
|
||||
}, time.Nanosecond, embyLatestStaleWindow)
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
items, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key)
|
||||
if !ok || !stale {
|
||||
t.Fatalf("expected a stale hit, got ok=%v stale=%v", ok, stale)
|
||||
}
|
||||
if len(items) != 1 || items[0]["Id"] != "stale-item" {
|
||||
t.Fatalf("unexpected stale payload: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaScanCounter 统计真正落到 media 表上的查询次数,用来断言「整库数据
|
||||
// 只扫一次」:GORM 的 Trace 回调里能拿到最终 SQL。
|
||||
type mediaScanCounter struct {
|
||||
logger.Interface
|
||||
mu sync.Mutex
|
||||
scans int
|
||||
}
|
||||
|
||||
func (c *mediaScanCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
sql, _ := fc()
|
||||
if strings.Contains(sql, "FROM `media`") {
|
||||
c.mu.Lock()
|
||||
c.scans++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mediaScanCounter) Scans() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.scans
|
||||
}
|
||||
|
||||
// TestVirtualSeriesLookupsShareOneScan 覆盖冷启动风暴的回归点:客户端刷新
|
||||
// 首页时 73 个库的卡片和海报各自要一个虚拟 series 分组。修好之后,第一次
|
||||
// 扫描把整库分组都写进缓存,同一页剩下的查询都不该再落 SQL;随后一个不存在
|
||||
// 的虚拟 ID 也不该反复触发整库重扫。
|
||||
func TestVirtualSeriesLookupsShareOneScan(t *testing.T) {
|
||||
counter := &mediaScanCounter{Interface: logger.Discard}
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||
|
||||
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
shows := []string{"Alpha", "Bravo", "Charlie"}
|
||||
rows := make([]model.Media, 0, len(shows)*2)
|
||||
for _, show := range shows {
|
||||
for ep := 1; ep <= 2; ep++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
|
||||
LibraryID: lib.ID,
|
||||
Title: show,
|
||||
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: ep,
|
||||
})
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
seriesIDs := map[string]string{}
|
||||
for i := range rows {
|
||||
if rows[i].EpisodeNum == 1 {
|
||||
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
|
||||
}
|
||||
}
|
||||
|
||||
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs["Alpha"], ""); err != nil || !found {
|
||||
t.Fatalf("findSeriesGroup(Alpha) = (%v, %v), want found", found, err)
|
||||
}
|
||||
if got := counter.Scans(); got != 1 {
|
||||
t.Fatalf("首次查询应恰好扫描 media 一次,实际 %d 次", got)
|
||||
}
|
||||
|
||||
for _, show := range []string{"Bravo", "Charlie"} {
|
||||
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs[show], ""); err != nil || !found {
|
||||
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", show, found, err)
|
||||
}
|
||||
}
|
||||
if got := counter.Scans(); got != 1 {
|
||||
t.Fatalf("同批分组应命中缓存,不应再扫 media,实际累计 %d 次", got)
|
||||
}
|
||||
|
||||
if _, found, err := svc.findSeriesGroup(t.Context(), embyVirtualSeriesPrefix+"missing", ""); err != nil || found {
|
||||
t.Fatalf("未知虚拟 ID 应为 not found,实际 found=%v err=%v", found, err)
|
||||
}
|
||||
if got := counter.Scans(); got != 1 {
|
||||
t.Fatalf("刚扫过整库后,未知 ID 不应重扫 media,实际累计 %d 次", got)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,17 @@ type EmbyService struct {
|
||||
virtualSeries map[string]embySeriesCacheEntry
|
||||
virtualSeasons map[string]embySeasonCacheEntry
|
||||
virtualArtwork map[string]embyArtworkCacheEntry
|
||||
// virtualGroups 把「整库虚拟剧集分组」的扫描折叠成一次。
|
||||
//
|
||||
// 客户端刷新首页时会同时请求 73 个库的 Latest,每个库的卡片和海报都要
|
||||
// 各自的虚拟 series 分组。不同剧的分组缓存键不同,singleflight 键也不同,
|
||||
// 于是第一波请求会并发重扫整张 media 表(生产环境实测冷启动 14-17s)。
|
||||
// 按用户折叠成一次扫描后,同一波请求共享结果并整批进缓存。
|
||||
virtualGroups singleflight.Group
|
||||
// virtualScanAt 记录每个用户最近一次整库扫描的时间,用于避免为同一个
|
||||
// 不存在的 id 反复重扫(必须按用户区分:可见性不同,别人的快照不能当
|
||||
// 自己的答案)。由 virtualMu 保护。
|
||||
virtualScanAt map[string]time.Time
|
||||
|
||||
visibilityMu sync.RWMutex
|
||||
visibilityCache map[string]embyVisibilityCacheEntry
|
||||
@@ -186,6 +197,15 @@ const (
|
||||
embyVirtualSeriesCap = 8000
|
||||
embyVirtualSeasonCap = 16000
|
||||
embyVirtualArtworkCap = 24000
|
||||
// embySeriesGroupBulkCacheEpisodes 限制一次批量记忆的剧集行数。
|
||||
//
|
||||
// 批量记忆把「一次扫描服务整页卡片 + 整页海报」的收益拿满,但同时让整库
|
||||
// 剧集常驻内存。超过该量级时退回只缓存命中的那一部,避免大库吃光内存。
|
||||
embySeriesGroupBulkCacheEpisodes = 8000
|
||||
// embyVirtualScanGrace 是「刚做过整库虚拟剧集扫描」的保护窗口。窗口内再
|
||||
// 请求一个不在缓存里的虚拟 series ID,直接按不存在处理,避免为同一个
|
||||
// 未知 id 反复重扫整库。
|
||||
embyVirtualScanGrace = 10 * time.Second
|
||||
// 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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type embyItemsCacheValue struct {
|
||||
@@ -77,7 +78,15 @@ func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) str
|
||||
}
|
||||
|
||||
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
|
||||
const defaultEmbyLatestCacheTTLSeconds = 300
|
||||
const defaultEmbyLatestCacheTTLSeconds = 900
|
||||
|
||||
// embyLatestStaleWindow 是「最新添加」载荷在进程内保留的陈旧副本时长。
|
||||
//
|
||||
// 客户端空闲很久之后再打开首页时,新鲜期早已过去:生产环境观察到这时
|
||||
// 73 个媒体库的 Latest 同时回源,首批请求要 8-25s 才回来,客户端等不及
|
||||
// 还会重试,进一步放大压力。保留陈旧副本后,这类请求立刻拿到上次的结果,
|
||||
// 由后台异步重建(见 refreshLatestItemsAsync),用户不再等 SQL。
|
||||
const embyLatestStaleWindow = 24 * time.Hour
|
||||
|
||||
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
|
||||
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
|
||||
|
||||
@@ -153,7 +153,7 @@ func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentI
|
||||
}
|
||||
if e.cache != nil {
|
||||
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
|
||||
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
|
||||
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+embyLatestStaleWindow)
|
||||
}
|
||||
e.rememberArtworkRefs(value.Artwork)
|
||||
return value, nil
|
||||
|
||||
+130
-40
@@ -41,47 +41,65 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
return embySeriesGroup{}, false, nil
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if group, ok := e.cachedSeriesGroup(id); ok {
|
||||
return group, true, nil
|
||||
}
|
||||
return e.findVirtualSeriesGroup(ctx, id, userID)
|
||||
}
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
q = q.Where("series_id = ?", id)
|
||||
} else {
|
||||
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
|
||||
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
|
||||
q = q.Where("series_id IS NULL OR series_id = ''")
|
||||
}
|
||||
q = q.Where("series_id = ?", id)
|
||||
if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
}
|
||||
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
bulkCached := e.rememberScannedSeriesGroups(groups)
|
||||
for _, group := range groups {
|
||||
if group.ID == id {
|
||||
e.rememberSeriesGroup(group)
|
||||
if !bulkCached {
|
||||
e.rememberSeriesGroup(group)
|
||||
}
|
||||
return group, true, nil
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
} else if series != nil {
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
DateLastMediaAdded: series.CreatedAt,
|
||||
}, true, nil
|
||||
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
} else if series != nil {
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
DateLastMediaAdded: series.CreatedAt,
|
||||
}, true, nil
|
||||
}
|
||||
return embySeriesGroup{}, false, nil
|
||||
}
|
||||
|
||||
// findVirtualSeriesGroup 处理 series_id 为空的剧集分组(虚拟 series ID)。
|
||||
//
|
||||
// 虚拟 ID 是 hash(库名 + 剧名),无法反解,只能整库算出分组后按 ID 取用。
|
||||
// 这里走 loadVirtualSeriesGroups,把同一用户的并发请求折叠成一次扫描:
|
||||
// 首页一次刷新会同时要 73 个库的卡片和海报,各扫一遍在 2 核主机上要 14-17s。
|
||||
func (e *EmbyService) findVirtualSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) {
|
||||
if group, ok := e.cachedSeriesGroup(id); ok {
|
||||
return group, true, nil
|
||||
}
|
||||
if e.virtualScanRecently(userID) {
|
||||
return embySeriesGroup{}, false, nil
|
||||
}
|
||||
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
}
|
||||
for _, group := range groups {
|
||||
if group.ID == id {
|
||||
return group, true, nil
|
||||
}
|
||||
}
|
||||
return embySeriesGroup{}, false, nil
|
||||
@@ -103,20 +121,14 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
return season, true, nil
|
||||
}
|
||||
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.
|
||||
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
// 复用共享扫描:首页并发刷新时整库只扫一次。
|
||||
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
for _, series := range groups {
|
||||
for _, season := range e.seasonsForSeries(series) {
|
||||
if season.ID == id {
|
||||
e.rememberSeriesGroup(series)
|
||||
return season, true, nil
|
||||
}
|
||||
}
|
||||
@@ -186,6 +198,84 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
// rememberScannedSeriesGroups 批量记住一次扫描得到的全部分组,返回是否真的
|
||||
// 批量写入(超过剧集预算时不做,调用方需自行缓存命中的那一部)。
|
||||
//
|
||||
// 只缓存命中的一部时,同一部剧的多张卡片/海报仍要各扫一次;整批写入后,
|
||||
// 同一次扫描算出来的其他剧也直接命中,于是整页 16 张卡片 + 16 张海报共用
|
||||
// 一次全表扫描。
|
||||
func (e *EmbyService) rememberScannedSeriesGroups(groups []embySeriesGroup) bool {
|
||||
if e == nil || len(groups) == 0 {
|
||||
return false
|
||||
}
|
||||
episodes := 0
|
||||
for i := range groups {
|
||||
episodes += len(groups[i].Episodes)
|
||||
if episodes > embySeriesGroupBulkCacheEpisodes {
|
||||
return false
|
||||
}
|
||||
}
|
||||
e.rememberSeriesGroups(groups)
|
||||
return true
|
||||
}
|
||||
|
||||
// loadVirtualSeriesGroups 返回 series_id 为空的整库剧集分组。
|
||||
//
|
||||
// 同一用户的并发调用共享同一次扫描:客户端刷新首页时 73 个库的 Latest 同时
|
||||
// 进来,每个库的卡片和海报又各自要自己的虚拟 series 分组。分组缓存键各不
|
||||
// 相同,singleflight 也就不会合并,于是第一波请求把整张 media 表并发重算
|
||||
// 了 73 次(生产环境实测 14-17s)。这里按用户折叠成一次,结果整批进缓存,
|
||||
// 调用方拿到切片后按 ID 取用即可。
|
||||
func (e *EmbyService) loadVirtualSeriesGroups(ctx context.Context, userID string) ([]embySeriesGroup, error) {
|
||||
if e == nil || e.repo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
v, err, _ := e.virtualGroups.Do("virtual:"+userID, func() (any, error) {
|
||||
// 与 loadLatestItemsCached 一致:一个客户端断开不应取消正在为其他
|
||||
// 客户端填充的共享扫描。
|
||||
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(loadCtx).Model(&model.Media{}).
|
||||
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(loadCtx, q, userID)
|
||||
if err := q.
|
||||
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(loadCtx, rows)
|
||||
e.rememberSeriesGroups(groups)
|
||||
e.markVirtualScan(userID)
|
||||
return groups, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, _ := v.([]embySeriesGroup)
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// markVirtualScan 记录该用户最近一次整库扫描的时间。
|
||||
func (e *EmbyService) markVirtualScan(userID string) {
|
||||
e.virtualMu.Lock()
|
||||
if e.virtualScanAt == nil {
|
||||
e.virtualScanAt = make(map[string]time.Time, 4)
|
||||
}
|
||||
e.virtualScanAt[userID] = time.Now()
|
||||
e.virtualMu.Unlock()
|
||||
}
|
||||
|
||||
// virtualScanRecently 报告该用户是否刚做过一次整库扫描。窗口内仍不在缓存里
|
||||
// 的目标 ID 按不存在处理,避免为同一个未知 ID 反复重扫整库。
|
||||
func (e *EmbyService) virtualScanRecently(userID string) bool {
|
||||
e.virtualMu.RLock()
|
||||
scannedAt := e.virtualScanAt[userID]
|
||||
e.virtualMu.RUnlock()
|
||||
return !scannedAt.IsZero() && time.Since(scannedAt) < embyVirtualScanGrace
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
|
||||
byID := map[string]*embySeriesGroup{}
|
||||
order := []string{}
|
||||
|
||||
@@ -26,19 +26,45 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||
if e == nil || strings.TrimSpace(group.ID) == "" {
|
||||
return
|
||||
}
|
||||
e.rememberSeriesGroups([]embySeriesGroup{group})
|
||||
}
|
||||
|
||||
// rememberSeriesGroups 批量记住一组剧集分组(含派生季与封面)。
|
||||
//
|
||||
// 一次全表扫描会同时算出整库的剧集分组,而调用方往往只关心其中一部。若只
|
||||
// 逐个 rememberSeriesGroup,同一次首页刷新里 16 张卡片 + 16 张海报都要各自
|
||||
// 命中自己的虚拟 series ID,未命中就重扫整张 media 表(生产环境实测单次
|
||||
// 1.3s,73 个库并发时把连接池排满,首页要 8-25s)。批量写入后冷缓存只付
|
||||
// 一次扫描,其余全部是 O(1) 命中。
|
||||
func (e *EmbyService) rememberSeriesGroups(groups []embySeriesGroup) {
|
||||
if e == nil || len(groups) == 0 {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(embyVirtualCacheTTL)
|
||||
e.virtualMu.Lock()
|
||||
defer e.virtualMu.Unlock()
|
||||
if e.virtualSeries == nil {
|
||||
e.virtualSeries = make(map[string]embySeriesCacheEntry)
|
||||
e.virtualSeries = make(map[string]embySeriesCacheEntry, len(groups))
|
||||
}
|
||||
if e.virtualSeasons == nil {
|
||||
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
|
||||
e.virtualSeasons = make(map[string]embySeasonCacheEntry, len(groups))
|
||||
}
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(groups)*2)
|
||||
}
|
||||
for _, group := range groups {
|
||||
e.rememberSeriesGroupLocked(group, expiresAt)
|
||||
}
|
||||
// 写入后再裁剪:逐条裁剪会让整批写入退化成 O(n²),且中途裁剪可能把
|
||||
// 同一批里刚写进去的分组又淘汰掉。
|
||||
e.trimVirtualCachesLocked(time.Now())
|
||||
}
|
||||
|
||||
// rememberSeriesGroupLocked 写入单个分组,调用方必须已持有 virtualMu。
|
||||
func (e *EmbyService) rememberSeriesGroupLocked(group embySeriesGroup, expiresAt time.Time) {
|
||||
if strings.TrimSpace(group.ID) == "" {
|
||||
return
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -2,6 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -278,6 +281,20 @@ func isSupportedEmbyCollectionType(t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// libraryCoverTag 生成媒体库拼图封面的图片版本号。
|
||||
//
|
||||
// 第三方客户端把库主图按 (URL, ImageTag) 缓存,tag 不变就不会重新请求。
|
||||
// 用成员海报列表的指纹做 tag,库内容变化或生成规则调整时客户端自然会刷新。
|
||||
func libraryCoverTag(libraryID string, sources []string) string {
|
||||
h := sha256.New()
|
||||
_, _ = io.WriteString(h, libraryID)
|
||||
for _, src := range sources {
|
||||
_, _ = io.WriteString(h, src)
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return libraryID + "-" + hex.EncodeToString(h.Sum(nil))[:8]
|
||||
}
|
||||
|
||||
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
|
||||
collectionType := "movies"
|
||||
switch l.Type {
|
||||
@@ -292,9 +309,17 @@ func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[s
|
||||
}
|
||||
// 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary,
|
||||
// 否则客户端会认为该库无主图而不去请求 /Images/Primary。
|
||||
//
|
||||
// 拼图封面的 tag 带上「成员海报」的指纹:库内容变化或生成规则调整(例如
|
||||
// 从 2×2 改成竖切)时 tag 会变,客户端才会丢缓存重新拉图;固定用库 ID
|
||||
// 会让第三方客户端永远显示第一次缓存下来的封面。
|
||||
imageTags := map[string]string{}
|
||||
if e.LibraryHasCover(ctx, l.ID) {
|
||||
imageTags["Primary"] = l.ID
|
||||
if sources, lib := LibraryCoverSourcesForLibrary(ctx, e.repo, l.ID); lib != nil {
|
||||
if len(sources) > 0 {
|
||||
imageTags["Primary"] = libraryCoverTag(l.ID, sources)
|
||||
} else if strings.TrimSpace(lib.CoverURL) != "" {
|
||||
imageTags["Primary"] = l.ID
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": l.ID,
|
||||
|
||||
@@ -136,7 +136,12 @@ func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, med
|
||||
SessionID: playSessionID,
|
||||
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
|
||||
})
|
||||
if err == nil {
|
||||
// 普通进度上报不清列表载荷缓存。播放期间客户端每 5-10 秒上报一次,
|
||||
// 每次清空 media:emby:* 会让「最新添加」整批 payload(客户端刷新首页时
|
||||
// 一次并发 73 个库)反复回源 SQL,生产环境实测因此出现 8-25s 的加载。
|
||||
// 续播位置由 /Items/Resume 与条目详情实时查库,列表里的进度条允许滞后;
|
||||
// 只有「已看完」这类状态翻转才让缓存失效(position 到达片尾 30s 内)。
|
||||
if err == nil && playbackProgressCompleted(pos, dur) {
|
||||
e.invalidateEmbyItemsCache(ctx)
|
||||
}
|
||||
return err
|
||||
@@ -287,10 +292,11 @@ func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool)
|
||||
return userData
|
||||
}
|
||||
|
||||
// embyInvalMu 节流全量缓存失效:播放期间客户端每 5-10s 上报一次进度,
|
||||
// 每次都 SCAN+DEL 全部 media:emby:* 缓存会把缓存命中率持续打穿(其他
|
||||
// 客户端每次翻页都回源 SQL)。条目载荷的 UserData 在请求时动态合并,
|
||||
// 进度类变更做 30s 节流即可,不影响正确性观感。
|
||||
// embyInvalMu 节流全量缓存失效:收藏/标记已看这类显式操作会改变列表里的
|
||||
// UserData,必须让 payload 失效。客户端连续点选时会短时间触发多次,节流到
|
||||
// 30s 一次即可,避免每次都 SCAN+DEL 全部 media:emby:* 缓存。
|
||||
// 播放进度上报不走这里(见 RecordProgressWithSession):续播位置实时查库,
|
||||
// 列表里的进度条允许滞后,不值得为它把整批首页载荷打回源 SQL。
|
||||
var (
|
||||
embyInvalMu sync.Mutex
|
||||
embyInvalLast time.Time
|
||||
|
||||
@@ -200,7 +200,7 @@ func imageResizeConcurrency() int {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return PruneImageCacheResult{}, nil
|
||||
@@ -209,11 +209,7 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||||
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
|
||||
}
|
||||
var originalsMaxAge time.Duration
|
||||
if p.cfg.Cache.ImagesOriginalsTTLHours > 0 {
|
||||
originalsMaxAge = time.Duration(p.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
|
||||
}
|
||||
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
pools := ImageCachePools(p.cacheDir, originalsMaxBytes, originalsMaxAge)
|
||||
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
|
||||
return PruneImageCachePools(pools, totalBytes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 媒体库封面拼图(Emby 风格的「封面集」)。
|
||||
//
|
||||
// Emby / Jellyfin 在没有管理员自定义封面时,会用库内几张海报拼成一张
|
||||
// 2×2 的合成图作为库主图(folder image)。MeBox 早先只是挑评分最高的
|
||||
// 那一张单图,于是每个库的封面看起来都像「随便选了一张」。这里按同样的
|
||||
// 思路生成拼图,供 Emby 客户端、Web 和 App 共用同一份图片。
|
||||
|
||||
const (
|
||||
// libraryCoverDefaultWidth / Height 是拼图输出的默认画布。
|
||||
//
|
||||
// 库条目的 PrimaryImageAspectRatio 报的是 16:9(见 emby_system.go),
|
||||
// 客户端按这个比例摆卡片;生成同样比例的图可以避免客户端再裁一刀。
|
||||
libraryCoverDefaultWidth = 480
|
||||
libraryCoverDefaultHeight = 270
|
||||
libraryCoverMinWidth = 96
|
||||
libraryCoverMaxWidth = 1920
|
||||
libraryCoverAspect = 16.0 / 9.0
|
||||
|
||||
// libraryCoverSourceLimit 是拼图实际取用的成员海报数上限。
|
||||
//
|
||||
// 封面是竖切分栏:三栏在 16:9 画布上每栏接近 3:5,既保留海报主体,又能
|
||||
// 让一个库有多部作品可辨识;再多就会把海报裁成很窄的竖条。
|
||||
libraryCoverSourceLimit = 3
|
||||
// libraryCoverMaxPanels 是合成函数能接受的最多分栏数(更长的来源列表
|
||||
// 只取前几张)。留 4 是给「按库类型调栏数」留的余量。
|
||||
libraryCoverMaxPanels = 4
|
||||
// libraryCoverCandidatePool 是挑选海报时的候选池大小。只看最近的一批
|
||||
// 成员,避免为一个库封面扫全库;对「最新入库」的库来说这批就是它最好
|
||||
// 的那几张。
|
||||
libraryCoverCandidatePool = 200
|
||||
|
||||
libraryCoverQuality = 85
|
||||
libraryCoverSubdir = "library-covers"
|
||||
)
|
||||
|
||||
// libraryCoverSourceCache 缓存「库 → 成员海报来源」。Emby 客户端进入首页
|
||||
// 时会对每个库各请求一次主图,把这个查询压到每库 10 分钟一次,避免几十个
|
||||
// 200 行的列表查询同时打进来。
|
||||
var libraryCoverSourceCache = struct {
|
||||
mu sync.Mutex
|
||||
items map[string]libraryCoverSourceEntry
|
||||
}{items: make(map[string]libraryCoverSourceEntry)}
|
||||
|
||||
type libraryCoverSourceEntry struct {
|
||||
sources []string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const libraryCoverSourceTTL = 10 * time.Minute
|
||||
|
||||
func cachedLibraryCoverSources(libraryID string) ([]string, bool) {
|
||||
libraryCoverSourceCache.mu.Lock()
|
||||
defer libraryCoverSourceCache.mu.Unlock()
|
||||
entry, ok := libraryCoverSourceCache.items[libraryID]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
if ok {
|
||||
delete(libraryCoverSourceCache.items, libraryID)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return entry.sources, true
|
||||
}
|
||||
|
||||
func rememberLibraryCoverSources(libraryID string, sources []string) {
|
||||
libraryCoverSourceCache.mu.Lock()
|
||||
defer libraryCoverSourceCache.mu.Unlock()
|
||||
if len(libraryCoverSourceCache.items) > 4000 {
|
||||
libraryCoverSourceCache.items = make(map[string]libraryCoverSourceEntry)
|
||||
}
|
||||
libraryCoverSourceCache.items[libraryID] = libraryCoverSourceEntry{
|
||||
sources: sources,
|
||||
expiresAt: time.Now().Add(libraryCoverSourceTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// LibraryCoverSources 返回媒体库封面拼图要用的海报来源,最多 limit 张。
|
||||
//
|
||||
// 选法与网页旧逻辑一致(库内最近一批成员,按 seriesArtworkScore 取质量
|
||||
// 最高者),额外按剧集去重,避免四张海报全是同一部剧的不同集。
|
||||
func LibraryCoverSources(ctx context.Context, repos *repository.Container, libraryID string, limit int) []string {
|
||||
if repos == nil || repos.Media == nil || strings.TrimSpace(libraryID) == "" {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > libraryCoverSourceLimit {
|
||||
limit = libraryCoverSourceLimit
|
||||
}
|
||||
if cached, ok := cachedLibraryCoverSources(libraryID); ok {
|
||||
if len(cached) > limit {
|
||||
return cached[:limit]
|
||||
}
|
||||
return cached
|
||||
}
|
||||
rows, err := repos.Media.ListByLibrariesFilteredNoCount(ctx, []string{libraryID}, 0, libraryCoverCandidatePool,
|
||||
repository.MediaQueryFilter{IncludeNSFW: true})
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
type candidate struct {
|
||||
url string
|
||||
key string
|
||||
score int
|
||||
}
|
||||
candidates := make([]candidate, 0, len(rows))
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
raw := strings.TrimSpace(row.PosterURL)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(row.BackdropURL)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
key := mediaSeriesKey(row)
|
||||
if key == "" {
|
||||
key = "id:" + row.ID
|
||||
}
|
||||
candidates = append(candidates, candidate{url: raw, key: key, score: seriesArtworkScore(row)})
|
||||
}
|
||||
// 评分高的优先;同分保持数据库顺序(最近的在前),封面更贴近「最新入库」。
|
||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
||||
|
||||
out := make([]string, 0, limit)
|
||||
seenKey := make(map[string]struct{}, limit)
|
||||
seenURL := make(map[string]struct{}, limit)
|
||||
for _, item := range candidates {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
if _, ok := seenKey[item.key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenURL[item.url]; ok {
|
||||
continue
|
||||
}
|
||||
seenKey[item.key] = struct{}{}
|
||||
seenURL[item.url] = struct{}{}
|
||||
out = append(out, item.url)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
rememberLibraryCoverSources(libraryID, nil)
|
||||
return nil
|
||||
}
|
||||
rememberLibraryCoverSources(libraryID, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LibraryCoverCacheDir 返回拼图缓存目录(与图片代理的 images/ 分开存放,
|
||||
// 不会被图片缓存的配额清理误删)。
|
||||
func LibraryCoverCacheDir(cfgCacheDir string) string {
|
||||
base := strings.TrimSpace(cfgCacheDir)
|
||||
if base == "" {
|
||||
base = "./cache"
|
||||
}
|
||||
return filepath.Join(base, libraryCoverSubdir)
|
||||
}
|
||||
|
||||
// LibraryCoverImage 生成(或命中缓存)媒体库封面拼图。
|
||||
//
|
||||
// width/height 是期望的输出画布;服务端会按 16:9 收敛,返回的图片不会
|
||||
// 超过该边界。sources 由调用方提供(见 LibraryCoverSources),这样 Emby
|
||||
// 路由和 Web API 可以共用同一套生成与缓存逻辑。
|
||||
func (p *ImageProxy) LibraryCoverImage(ctx context.Context, libraryID string, sources []string, width, height int) ([]byte, error) {
|
||||
if p == nil {
|
||||
return nil, errors.New("image proxy unavailable")
|
||||
}
|
||||
libraryID = strings.TrimSpace(libraryID)
|
||||
sources = normalizeCoverSources(sources)
|
||||
if libraryID == "" || len(sources) == 0 {
|
||||
return nil, errors.New("no library cover sources")
|
||||
}
|
||||
outW, outH := libraryCoverCanvas(width, height)
|
||||
cachePath, sourceKey := p.libraryCoverCachePath(libraryID, sources, outW, outH)
|
||||
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
|
||||
return data, nil
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("library-cover:%s-%dx%d-%s", libraryID, outW, outH, sourceKey)
|
||||
v, err, _ := p.fetchGroup.Do(key, func() (any, error) {
|
||||
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
|
||||
return data, nil
|
||||
}
|
||||
data, buildErr := p.buildLibraryCover(ctx, libraryID, sources, outW, outH, cachePath, sourceKey)
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _ := v.([]byte)
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty library cover")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *ImageProxy) buildLibraryCover(ctx context.Context, libraryID string, sources []string, width, height int, cachePath, sourceKey string) ([]byte, error) {
|
||||
images := p.fetchCoverSources(ctx, sources)
|
||||
if len(images) == 0 {
|
||||
return nil, errors.New("no library cover image fetched")
|
||||
}
|
||||
// 解码与合成是 CPU 密集操作:和缩略图共用同一组解码槽,避免一次首页
|
||||
// 刷新把 2 核主机打满。
|
||||
release, ok := p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
|
||||
if ok {
|
||||
defer release()
|
||||
}
|
||||
data, err := composeLibraryCover(images, width, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
return data, nil
|
||||
}
|
||||
if err := writeFileAtomic(cachePath, data); err != nil {
|
||||
p.warn("write library cover cache failed", err)
|
||||
return data, nil
|
||||
}
|
||||
p.pruneLibraryCoverVariants(filepath.Dir(cachePath), libraryID, sourceKey)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// fetchCoverSources 并发拉取成员海报(受图片代理自身的缓存/鉴权/SSRF 规则
|
||||
// 保护)。单张失败只丢弃该张,其余仍然可以拼图。
|
||||
func (p *ImageProxy) fetchCoverSources(ctx context.Context, sources []string) [][]byte {
|
||||
out := make([][]byte, len(sources))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, libraryCoverMaxPanels)
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
for i, raw := range sources {
|
||||
i, raw := i, raw
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
data, err := p.coverSourceBytes(fetchCtx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
out[i] = data
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
compacted := make([][]byte, 0, len(out))
|
||||
for _, data := range out {
|
||||
if len(data) > 0 {
|
||||
compacted = append(compacted, data)
|
||||
}
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
// coverSourceBytes 读取一张海报来源。
|
||||
//
|
||||
// 刮削会把 sidecar 海报存成 /media/... 这类绝对路径,而 fetch 路径只接受
|
||||
// http(s)(validateURL 要求有 host),直接丢给 Fetch 会全部失败。本地路径
|
||||
// 按图片代理同一套 isAllowedLocalPath 白名单读盘,远程地址才走 Fetch,
|
||||
// 这样鉴权、SSRF 校验与磁盘缓存的行为与 /api/img 完全一致。
|
||||
func (p *ImageProxy) coverSourceBytes(ctx context.Context, raw string) ([]byte, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.New("empty cover source")
|
||||
}
|
||||
if isLocalImagePath(raw) {
|
||||
abs, err := filepath.Abs(filepath.Clean(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !p.isAllowedLocalPath(abs) {
|
||||
return nil, errors.New("local cover path not allowed")
|
||||
}
|
||||
return os.ReadFile(abs) // #nosec G304 -- path validated against the library roots above.
|
||||
}
|
||||
data, _, err := p.Fetch(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// libraryCoverCachePath 生成缓存路径。
|
||||
//
|
||||
// 文件名里的 sourceKey 只由成员海报列表决定(不含尺寸):成员变化时换一批
|
||||
// 文件,同一批成员的多个尺寸变体可以共存,不会被互相删掉。size 单独进文件名,
|
||||
// 让 Emby(按客户端 maxWidth/maxHeight)与 Web/App(固定档位)各用自己的成品。
|
||||
func (p *ImageProxy) libraryCoverCachePath(libraryID string, sources []string, width, height int) (string, string) {
|
||||
h := sha256.New()
|
||||
for _, src := range sources {
|
||||
_, _ = io.WriteString(h, src)
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
sourceKey := hex.EncodeToString(h.Sum(nil))[:16]
|
||||
name := fmt.Sprintf("%s-%dx%d-%s.jpg", safeCoverFileID(libraryID), width, height, sourceKey)
|
||||
return filepath.Join(LibraryCoverCacheDir(p.cacheDirBase()), name), sourceKey
|
||||
}
|
||||
|
||||
func (p *ImageProxy) cacheDirBase() string {
|
||||
if p != nil && p.cfg != nil {
|
||||
return p.cfg.Cache.CacheDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pruneLibraryCoverVariants 清掉同一个库、旧成员列表留下的封面文件(含各
|
||||
// 尺寸变体),当前成员列表对应的成品全部保留。
|
||||
func (p *ImageProxy) pruneLibraryCoverVariants(dir, libraryID, keepSourceKey string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
prefix := safeCoverFileID(libraryID) + "-"
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".jpg") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, "-"+keepSourceKey+".jpg") {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, name)) // #nosec G304 -- name comes from ReadDir of our own cache dir.
|
||||
}
|
||||
}
|
||||
|
||||
// safeCoverFileID 让文件名只包含安全字符(库 ID 来自数据库,但仍做一次
|
||||
// 白名单过滤,避免任何路径分隔符进入文件名)。
|
||||
func safeCoverFileID(libraryID string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range libraryID {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "library"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func normalizeCoverSources(sources []string) []string {
|
||||
seen := make(map[string]struct{}, len(sources))
|
||||
out := make([]string, 0, len(sources))
|
||||
for _, raw := range sources {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[raw]; ok {
|
||||
continue
|
||||
}
|
||||
seen[raw] = struct{}{}
|
||||
out = append(out, raw)
|
||||
if len(out) >= libraryCoverMaxPanels {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// libraryCoverCanvas 把请求的尺寸收敛到 16:9 画布,并夹在合理区间内。
|
||||
func libraryCoverCanvas(width, height int) (int, int) {
|
||||
if width <= 0 && height <= 0 {
|
||||
return libraryCoverDefaultWidth, libraryCoverDefaultHeight
|
||||
}
|
||||
if width <= 0 {
|
||||
width = int(float64(height)*libraryCoverAspect + 0.5)
|
||||
}
|
||||
if height <= 0 {
|
||||
height = int(float64(width)/libraryCoverAspect + 0.5)
|
||||
}
|
||||
// 客户端给的是一个「不超过」的盒子:按 16:9 装进去。
|
||||
if float64(width)/float64(height) > libraryCoverAspect {
|
||||
width = int(float64(height)*libraryCoverAspect + 0.5)
|
||||
} else {
|
||||
height = int(float64(width)/libraryCoverAspect + 0.5)
|
||||
}
|
||||
if width < libraryCoverMinWidth {
|
||||
width = libraryCoverMinWidth
|
||||
height = int(float64(width)/libraryCoverAspect + 0.5)
|
||||
}
|
||||
if width > libraryCoverMaxWidth {
|
||||
width = libraryCoverMaxWidth
|
||||
height = int(float64(width)/libraryCoverAspect + 0.5)
|
||||
}
|
||||
return width, height
|
||||
}
|
||||
|
||||
// composeLibraryCover 把若干张海报拼成库封面:竖切成若干等宽竖条。
|
||||
//
|
||||
// - 1 张:铺满整块画布;
|
||||
// - 2 张:左右各一半(默认形态,对应网页/客户端卡片的分屏观感);
|
||||
// - 3/4 张:三等分 / 四等分竖条。
|
||||
//
|
||||
// 每格按中心裁切铺满(crop-to-fill),这样不同比例的海报都能对齐分栏。
|
||||
func composeLibraryCover(images [][]byte, width, height int) ([]byte, error) {
|
||||
if len(images) == 0 {
|
||||
return nil, errors.New("no images to compose")
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return nil, errors.New("invalid cover size")
|
||||
}
|
||||
decoded := make([]image.Image, 0, len(images))
|
||||
for _, data := range images {
|
||||
img, ok := decodeCoverImage(data)
|
||||
if ok {
|
||||
decoded = append(decoded, img)
|
||||
}
|
||||
if len(decoded) >= libraryCoverMaxPanels {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, errors.New("no decodable cover images")
|
||||
}
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
cells := make([]image.Rectangle, 0, len(decoded))
|
||||
if len(decoded) == 1 {
|
||||
cells = append(cells, canvas.Bounds())
|
||||
} else {
|
||||
for i := range decoded {
|
||||
x0 := width * i / len(decoded)
|
||||
x1 := width * (i + 1) / len(decoded)
|
||||
cells = append(cells, image.Rect(x0, 0, x1, height))
|
||||
}
|
||||
}
|
||||
for i, cell := range cells {
|
||||
drawCoverCell(canvas, cell, decoded[i])
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: libraryCoverQuality}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// decodeCoverImage 解码一张海报,并对异常大的图做像素上限保护:一次拼图
|
||||
// 会同时解码 4 张,2 核 2G 的机器上不能任由超大原图把内存吃光。
|
||||
func decodeCoverImage(data []byte) (image.Image, bool) {
|
||||
if len(data) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
if cfg.Width*cfg.Height > imageResizeMaxSourcePixels {
|
||||
return nil, false
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return img, true
|
||||
}
|
||||
|
||||
// drawCoverCell 把一张图按中心裁切铺满目标格子。
|
||||
func drawCoverCell(dst *image.RGBA, cell image.Rectangle, src image.Image) {
|
||||
if src == nil || cell.Dx() <= 0 || cell.Dy() <= 0 {
|
||||
return
|
||||
}
|
||||
bounds := src.Bounds()
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return
|
||||
}
|
||||
targetAspect := float64(cell.Dx()) / float64(cell.Dy())
|
||||
srcAspect := float64(srcW) / float64(srcH)
|
||||
crop := bounds
|
||||
if srcAspect > targetAspect {
|
||||
cropW := int(float64(srcH)*targetAspect + 0.5)
|
||||
if cropW < 1 {
|
||||
cropW = 1
|
||||
}
|
||||
x0 := bounds.Min.X + (srcW-cropW)/2
|
||||
crop = image.Rect(x0, bounds.Min.Y, x0+cropW, bounds.Max.Y)
|
||||
} else if srcAspect < targetAspect {
|
||||
cropH := int(float64(srcW)/targetAspect + 0.5)
|
||||
if cropH < 1 {
|
||||
cropH = 1
|
||||
}
|
||||
y0 := bounds.Min.Y + (srcH-cropH)/2
|
||||
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+cropH)
|
||||
}
|
||||
scale := float64(cell.Dx()) / float64(crop.Dx())
|
||||
var scaler draw.Scaler = draw.CatmullRom
|
||||
if scale < imageResizeCheapScaleRatio {
|
||||
scaler = draw.ApproxBiLinear
|
||||
}
|
||||
scaler.Scale(dst, cell, src, crop, draw.Src, nil)
|
||||
}
|
||||
|
||||
// writeFileAtomic 先写临时文件再 rename,避免并发的读取方读到半个文件。
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".cover-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibraryCoverSourcesForLibrary 是给 handler 的便捷封装:库存在且没有自定
|
||||
// 义封面时返回成员海报来源;远程挂载库由各自的上游负责封面,不在此生成。
|
||||
func LibraryCoverSourcesForLibrary(ctx context.Context, repos *repository.Container, libraryID string) ([]string, *model.Library) {
|
||||
if repos == nil || repos.Library == nil {
|
||||
return nil, nil
|
||||
}
|
||||
libraryID = strings.TrimSpace(libraryID)
|
||||
if libraryID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
lib, err := repos.Library.FindByID(ctx, libraryID)
|
||||
if err != nil || lib == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(lib.CoverURL) != "" {
|
||||
return nil, lib
|
||||
}
|
||||
return LibraryCoverSources(ctx, repos, libraryID, libraryCoverSourceLimit), lib
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func solidJPEG(t *testing.T, c color.Color, w, h int) []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, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decodeJPEG(t *testing.T, data []byte) image.Image {
|
||||
t.Helper()
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode composed cover: %v", err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// TestComposeLibraryCoverSplitsIntoSideBySideColumns 保证多张海报是竖切成
|
||||
// 左右分栏(左→右顺序),而不是 2×2 田字格。
|
||||
func TestComposeLibraryCoverSplitsIntoSideBySideColumns(t *testing.T) {
|
||||
images := [][]byte{
|
||||
solidJPEG(t, color.RGBA{R: 220, A: 255}, 200, 300),
|
||||
solidJPEG(t, color.RGBA{G: 220, A: 255}, 200, 300),
|
||||
solidJPEG(t, color.RGBA{B: 220, A: 255}, 200, 300),
|
||||
solidJPEG(t, color.RGBA{R: 220, G: 220, A: 255}, 200, 300),
|
||||
}
|
||||
out, err := composeLibraryCover(images, 480, 270)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
img := decodeJPEG(t, out)
|
||||
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
|
||||
t.Fatalf("size = %v, want 480x270", img.Bounds())
|
||||
}
|
||||
// 四张图 → 四条竖栏;同一栏从顶部到底部颜色一致(竖切,不是上下分割)。
|
||||
y := img.Bounds().Dy() / 2
|
||||
checks := []struct {
|
||||
name string
|
||||
x int
|
||||
dominant func(color.RGBA) bool
|
||||
}{
|
||||
{"column 1 red", 60, func(c color.RGBA) bool { return c.R > c.G+40 && c.R > c.B+40 }},
|
||||
{"column 2 green", 180, func(c color.RGBA) bool { return c.G > c.R+40 && c.G > c.B+40 }},
|
||||
{"column 3 blue", 300, func(c color.RGBA) bool { return c.B > c.R+40 && c.B > c.G+40 }},
|
||||
{"column 4 yellow", 420, func(c color.RGBA) bool { return c.R > c.B+40 && c.G > c.B+40 }},
|
||||
}
|
||||
for _, check := range checks {
|
||||
for _, yy := range []int{2, y, img.Bounds().Dy() - 3} {
|
||||
r, g, b, _ := img.At(check.x, yy).RGBA()
|
||||
got := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}
|
||||
if !check.dominant(got) {
|
||||
t.Fatalf("%s at y=%d: pixel = %v", check.name, yy, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeLibraryCoverHandlesFewerPosters 覆盖 1/2/3 张成员的布局。
|
||||
func TestComposeLibraryCoverHandlesFewerPosters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{"single", 1},
|
||||
{"pair", 2},
|
||||
{"triple", 3},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
images := make([][]byte, 0, tc.count)
|
||||
for i := 0; i < tc.count; i++ {
|
||||
images = append(images, solidJPEG(t, color.RGBA{R: 200, G: 120, A: 255}, 300, 450))
|
||||
}
|
||||
out, err := composeLibraryCover(images, 320, 180)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
img := decodeJPEG(t, out)
|
||||
if img.Bounds().Dx() != 320 || img.Bounds().Dy() != 180 {
|
||||
t.Fatalf("size = %v, want 320x180", img.Bounds())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeLibraryCoverSkipsUndecodableSources 保证一张坏图不会让整张
|
||||
// 封面失败:其余可解码的成员仍然会拼出来。
|
||||
func TestComposeLibraryCoverSkipsUndecodableSources(t *testing.T) {
|
||||
images := [][]byte{
|
||||
[]byte("not an image"),
|
||||
solidJPEG(t, color.RGBA{G: 200, A: 255}, 200, 300),
|
||||
}
|
||||
out, err := composeLibraryCover(images, 240, 135)
|
||||
if err != nil {
|
||||
t.Fatalf("compose: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected composed cover bytes")
|
||||
}
|
||||
if _, err := composeLibraryCover([][]byte{[]byte("nope")}, 240, 135); err == nil {
|
||||
t.Fatal("expected error when no source is decodable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryCoverCanvasKeepsSixteenByNine(t *testing.T) {
|
||||
cases := []struct {
|
||||
inW, inH int
|
||||
wantW int
|
||||
wantH int
|
||||
checkRatio bool
|
||||
}{
|
||||
{0, 0, 480, 270, true},
|
||||
{640, 480, 640, 360, true}, // 4:3 盒子 → 装进 16:9
|
||||
{2000, 0, 1920, 1080, true}, // 超上限 → 夹紧
|
||||
{40, 0, 96, 54, true}, // 低于下限 → 抬到最小宽度
|
||||
{0, 540, 960, 540, false}, // 只给高度 → 按 16:9 推宽度
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w, h := libraryCoverCanvas(tc.inW, tc.inH)
|
||||
if w != tc.wantW || h != tc.wantH {
|
||||
t.Fatalf("libraryCoverCanvas(%d,%d) = %dx%d, want %dx%d", tc.inW, tc.inH, w, h, tc.wantW, tc.wantH)
|
||||
}
|
||||
if tc.checkRatio && w != 0 && h != 0 {
|
||||
ratio := float64(w) / float64(h)
|
||||
if ratio < 1.7 || ratio > 1.85 {
|
||||
t.Fatalf("ratio for %dx%d = %.3f, want ~1.78", tc.inW, tc.inH, ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryCoverSourcesDedupesSeries 保证封面不会两张都是同一部剧的不同集,
|
||||
// 并且优先取海报、没有海报时退回剧照、完全没有图的成员被跳过。
|
||||
func TestLibraryCoverSourcesDedupesSeries(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "a1"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E01.mkv", SeriesID: "series-a", PosterURL: "https://img/a1.jpg"},
|
||||
{Base: model.Base{ID: "a2"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E02.mkv", SeriesID: "series-a", PosterURL: "https://img/a2.jpg"},
|
||||
{Base: model.Base{ID: "b1"}, LibraryID: lib.ID, Title: "B", Path: "/media/tv/B/S01E01.mkv", SeriesID: "series-b", PosterURL: "https://img/b1.jpg"},
|
||||
{Base: model.Base{ID: "c1"}, LibraryID: lib.ID, Title: "C", Path: "/media/tv/C/S01E01.mkv", SeriesID: "series-c", BackdropURL: "https://img/c1-backdrop.jpg"},
|
||||
{Base: model.Base{ID: "d1"}, LibraryID: lib.ID, Title: "D", Path: "/media/tv/D/S01E01.mkv", SeriesID: "series-d"},
|
||||
}
|
||||
for i := range rows {
|
||||
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
// 竖切三栏:一部剧只出一张,无图成员被跳过 → 正好 3 张。
|
||||
sources := LibraryCoverSources(t.Context(), svc.repo, lib.ID, libraryCoverSourceLimit)
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("sources = %v, want 3 (一部剧一张、跳过无图成员)", sources)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, src := range sources {
|
||||
if seen[src] {
|
||||
t.Fatalf("duplicate source in %v", sources)
|
||||
}
|
||||
seen[src] = true
|
||||
}
|
||||
// 同一部剧只会出现一次:具体保留哪一集由查询顺序决定,但绝不能两张。
|
||||
if seen["https://img/a1.jpg"] && seen["https://img/a2.jpg"] {
|
||||
t.Fatalf("same series contributed two posters: %v", sources)
|
||||
}
|
||||
if !seen["https://img/a1.jpg"] && !seen["https://img/a2.jpg"] {
|
||||
t.Fatalf("series A is missing from sources: %v", sources)
|
||||
}
|
||||
if !seen["https://img/c1-backdrop.jpg"] {
|
||||
t.Fatalf("member without poster should fall back to backdrop: %v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryCoverImageCachesComposedBytes 验证生成结果会落盘复用:同一份
|
||||
// 内容第二次请求不再回源。
|
||||
func TestLibraryCoverImageCachesComposedBytes(t *testing.T) {
|
||||
var hits int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(solidJPEG(t, color.RGBA{R: 180, G: 140, B: 90, A: 255}, 240, 360))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
parsed, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse upstream: %v", err)
|
||||
}
|
||||
cacheCfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
|
||||
proxy := NewImageProxy(cacheCfg, zap.NewNop())
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
|
||||
|
||||
sources := []string{upstream.URL + "/1.jpg", upstream.URL + "/2.jpg", upstream.URL + "/3.jpg", upstream.URL + "/4.jpg"}
|
||||
first, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
|
||||
if err != nil {
|
||||
t.Fatalf("first cover: %v", err)
|
||||
}
|
||||
if img := decodeJPEG(t, first); img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
|
||||
t.Fatalf("size = %v, want 480x270", img.Bounds())
|
||||
}
|
||||
hitsAfterFirst := atomic.LoadInt32(&hits)
|
||||
if hitsAfterFirst != 4 {
|
||||
t.Fatalf("upstream hits = %d, want one per source", hitsAfterFirst)
|
||||
}
|
||||
|
||||
second, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
|
||||
if err != nil {
|
||||
t.Fatalf("second cover: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("cached cover bytes differ from the first build")
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst {
|
||||
t.Fatalf("cache miss: upstream hits %d -> %d", hitsAfterFirst, got)
|
||||
}
|
||||
|
||||
// 内容变了要重新生成(成员海报换了新文件)。已缓存的三张海报不会重下,
|
||||
// 只有新增的那张会回源。
|
||||
changed := append([]string{}, sources[:3]...)
|
||||
changed = append(changed, upstream.URL+"/5.jpg")
|
||||
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 480, 270); err != nil {
|
||||
t.Fatalf("cover after content change: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst+1 {
|
||||
t.Fatalf("content change should rebuild with one new fetch, hits = %d", got)
|
||||
}
|
||||
|
||||
// 同一批成员的另一个尺寸档位要能共存:Web/App 与 Emby 请求的尺寸不同,
|
||||
// 不能互相把对方的成品当「旧内容」删掉。
|
||||
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 400, 225); err != nil {
|
||||
t.Fatalf("other size cover: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(LibraryCoverCacheDir(cacheCfg.Cache.CacheDir))
|
||||
if err != nil {
|
||||
t.Fatalf("read cover cache dir: %v", err)
|
||||
}
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 480x270 and 400x225 variants to coexist, got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryCoverSourceBytesReadsLocalSidecarPoster 覆盖刮削把海报存成
|
||||
// /media/... 本地绝对路径的情况:这类来源没有 http(s) host,不能走 Fetch,
|
||||
// 必须按库根目录白名单直接读盘,否则整张拼图会因为「没有可解码的海报」而
|
||||
// 生成失败(生产环境所有本地库都是这种来源)。
|
||||
func TestLibraryCoverSourceBytesReadsLocalSidecarPoster(t *testing.T) {
|
||||
libDir := t.TempDir()
|
||||
posterPath := filepath.Join(libDir, "poster.jpg")
|
||||
if err := os.WriteFile(posterPath, solidJPEG(t, color.RGBA{R: 180, G: 90, A: 255}, 200, 300), 0o644); err != nil {
|
||||
t.Fatalf("write poster: %v", err)
|
||||
}
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
// 未登记为库根目录时必须拒绝读取。
|
||||
if _, err := proxy.coverSourceBytes(t.Context(), posterPath); err == nil {
|
||||
t.Fatal("expected local path outside the library roots to be rejected")
|
||||
}
|
||||
|
||||
proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} })
|
||||
data, err := proxy.coverSourceBytes(t.Context(), posterPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read local poster: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("expected poster bytes")
|
||||
}
|
||||
if _, ok := decodeCoverImage(data); !ok {
|
||||
t.Fatal("local poster should decode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryCoverSourcesForLibrarySkipsCustomCover 保证管理员填了自定义
|
||||
// 封面时不会再生成拼图(沿用自定义图)。
|
||||
func TestLibraryCoverSourcesForLibrarySkipsCustomCover(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", CoverURL: "https://example.com/custom.jpg"}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := svc.repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "m1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "M",
|
||||
Path: "/media/movies/m1.mkv",
|
||||
PosterURL: "https://img/m1.jpg",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
sources, found := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, lib.ID)
|
||||
if found == nil || found.CoverURL != lib.CoverURL {
|
||||
t.Fatalf("library = %#v, want the existing library", found)
|
||||
}
|
||||
if len(sources) != 0 {
|
||||
t.Fatalf("expected no generated sources with a custom cover, got %v", sources)
|
||||
}
|
||||
if _, blank := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, "missing"); blank != nil {
|
||||
t.Fatalf("unknown library should return nil, got %#v", blank)
|
||||
}
|
||||
}
|
||||
@@ -126,13 +126,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
}
|
||||
cfg.Cache.ImagesOriginalsMaxSizeMB = n
|
||||
}
|
||||
case "cache.images_originals_ttl_hours":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
cfg.Cache.ImagesOriginalsTTLHours = n
|
||||
}
|
||||
case "cache.memory_max_size_mb":
|
||||
n := config.DefaultCacheMemoryMaxSizeMB
|
||||
if value != "" {
|
||||
|
||||
@@ -80,10 +80,10 @@ func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
|
||||
}
|
||||
|
||||
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
|
||||
// 只有容量配额,没有保留时长:淘汰只看占用,不按文件年龄删除。
|
||||
type ImageCachePolicy struct {
|
||||
TotalBytes int64
|
||||
OriginalsBytes int64
|
||||
OriginalsAge time.Duration
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) {
|
||||
|
||||
@@ -299,18 +299,18 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
return walkAndPrune(s.cacheDir+"/hls", cutoff)
|
||||
}
|
||||
|
||||
// jobCleanImageCache prunes the image proxy cache: 原图按保留时长与独立配额
|
||||
// 优先淘汰,总量超限时再淘汰派生成品。
|
||||
// jobCleanImageCache prunes the image proxy cache: 原图按独立配额优先淘汰,
|
||||
// 总量超限时再淘汰派生成品。全程只看占用,不按文件年龄删除。
|
||||
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
return nil
|
||||
}
|
||||
policy := s.imageCachePolicy()
|
||||
if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 && policy.OriginalsAge <= 0 {
|
||||
if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
imagesDir := filepath.Join(s.cacheDir, "images")
|
||||
pools := ImageCachePools(imagesDir, policy.OriginalsBytes, policy.OriginalsAge)
|
||||
pools := ImageCachePools(imagesDir, policy.OriginalsBytes)
|
||||
res, err := PruneImageCachePools(pools, policy.TotalBytes)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -222,9 +221,6 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
if b.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||||
policy.OriginalsBytes = int64(b.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
|
||||
}
|
||||
if b.cfg.Cache.ImagesOriginalsTTLHours > 0 {
|
||||
policy.OriginalsAge = time.Duration(b.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
|
||||
}
|
||||
return policy
|
||||
})
|
||||
// 阅读正文缓存清理:由阅读模块自己按 TTL + 容量 LRU 淘汰(见 reader_content_cache.go)。
|
||||
|
||||
@@ -464,7 +464,9 @@ export function HomeLibrariesSection({
|
||||
pinned={pinnedIds.includes(lib.id)}
|
||||
onTogglePin={onTogglePin ? () => onTogglePin(lib.id) : undefined}
|
||||
onVisible={() => {
|
||||
if (!lib.cover_url) {
|
||||
// 服务端会生成库封面时不必再为首屏卡片拉成员预览;
|
||||
// 远程挂载库的封面仍由卡片拼图兜底。
|
||||
if (!lib.cover_url && !lib.cover_image_url) {
|
||||
queuePreview(lib.id)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -43,5 +43,12 @@ export function getLibraryArtworks(
|
||||
if (library.cover_url) {
|
||||
return [{ src: library.cover_url, version: library.updated_at }]
|
||||
}
|
||||
// 服务端生成的库封面(成员海报竖切拼图):所有客户端共用一张图,
|
||||
// 不再由前端拿两张卡片自己拼格子。
|
||||
// version 里带一个版次后缀:接口地址是固定的,靠它让浏览器在拼图规则
|
||||
// 调整(如田字格改成竖切)后丢掉旧缓存重新拉图。
|
||||
if (library.cover_image_url) {
|
||||
return [{ src: library.cover_image_url, version: `${library.updated_at}-c3` }]
|
||||
}
|
||||
return libraryArtworkItems(cards)
|
||||
}
|
||||
|
||||
@@ -130,14 +130,6 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
defaultValue: '256',
|
||||
placeholder: '256',
|
||||
},
|
||||
{
|
||||
key: 'cache.images_originals_ttl_hours',
|
||||
label: '原图保留时长 (小时)',
|
||||
type: 'number',
|
||||
hint: '原图缓存的最长保留时间,超过即删除(默认 168 小时 = 7 天)。缩略图都已生成时原图就不再需要,缩短它可以让原图缓存保持在一个滚动窗口内。设为 0 表示不按时间淘汰',
|
||||
defaultValue: '168',
|
||||
placeholder: '168',
|
||||
},
|
||||
{
|
||||
key: 'https.enabled',
|
||||
label: '启用 HTTPS',
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface Library {
|
||||
path: string
|
||||
type: string
|
||||
cover_url?: string
|
||||
/**
|
||||
* 服务端生成的封面地址(成员海报拼图)。管理员自定义 cover_url 时等于
|
||||
* 该地址,否则指向 `/api/libraries/:id/cover`。Web/App 统一读这个字段,
|
||||
* 不用再自己用卡片拼格子。
|
||||
*/
|
||||
cover_image_url?: string
|
||||
enabled: boolean
|
||||
sort_order?: number
|
||||
carousel_enabled?: boolean
|
||||
|
||||
Reference in New Issue
Block a user