Compare commits

..

7 Commits

Author SHA1 Message Date
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
truewhile 54b0e33968 优化 2026-10-22 00:44:37 +08:00
truewhile 74487359bf 优 2026-10-21 13:21:13 +08:00
67 changed files with 4363 additions and 256 deletions
+8
View File
@@ -113,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
BIN
View File
Binary file not shown.
+1 -2
View File
@@ -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)
-3
View File
@@ -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
}
+2 -4
View File
@@ -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"`
+5
View File
@@ -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`,
+1 -1
View File
@@ -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")
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+19
View File
@@ -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.
+6
View File
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
+4 -2
View File
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+2 -2
View File
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
+100
View File
@@ -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())
}
}
+228
View File
@@ -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())
}
}
+27 -6
View File
@@ -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)})
}
}
}
+72 -1
View File
@@ -1,13 +1,18 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
// GET /playback/:id/segments
// PUT /playback/:id/segments {kind, position_ms, clear}
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
//
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
// 外部数据永远由提供方刷新,不接受客户端写入。
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
}
// playbackSegmentsMarkRequest 是手动标记的入参。
type playbackSegmentsMarkRequest struct {
// Kind 只接受 intro / credits。
Kind string `json:"kind" binding:"required"`
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
PositionMs int64 `json:"position_ms"`
// Clear 为 true 时删除该类型的标记。
Clear bool `json:"clear"`
}
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
//
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
// 最高),避免自己再发一次 GET 造成竞态。
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Segments == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
return
}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
if service.IsEmbyRemoteID(m.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
return
}
var req playbackSegmentsMarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
Kind: req.Kind,
PositionMs: req.PositionMs,
Clear: req.Clear,
})
if errors.Is(err, service.ErrInvalidManualMark) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
return
}
if err != nil {
if svc.Log != nil {
svc.Log.Warn("save manual skip mark failed",
zap.String("media_id", m.ID), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil {
if svc.Log != nil {
svc.Log.Debug("list media segments after mark failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
}
c.JSON(http.StatusOK, gin.H{
"segments": service.ToSegmentViews(rows),
"auto_skip": resolveAutoSkipFlag(c, svc),
})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
@@ -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,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+14
View File
@@ -27,6 +27,10 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -34,6 +38,12 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
if limit <= 0 || limit > 500 {
limit = 50
}
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -42,7 +52,11 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+72
View File
@@ -103,3 +103,75 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
+8 -2
View File
@@ -237,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
if libraryID == "" || title == "" {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
// 功能在未刮削的库上完全不生效。
query = query.Where("library_id = ? AND title = ?", libraryID, title)
if m.TMDbID > 0 {
query = query.Where("tm_db_id = ?", m.TMDbID)
}
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
})
}
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
// triple in a single transaction. Used by manual marking, where the user sets
// one boundary at a time and must not disturb the other kinds.
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
+22 -34
View File
@@ -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},
+44 -44
View File
@@ -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)
}
}
+37 -1
View File
@@ -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.
@@ -325,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
}
}
// 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。
// 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查
// (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
episodic := true
if p.ParentID != "" {
if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil {
episodic = value
}
}
if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回
// mediaItems,由它按 Movie 过滤,而不是整行判空。
if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") {
return emptyItemsEnvelope(p.StartIndex), nil
}
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
+10 -1
View File
@@ -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(生产环境
+1 -1
View File
@@ -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
View File
@@ -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{}
+29 -3
View File
@@ -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}
+27 -2
View File
@@ -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,
+11 -5
View File
@@ -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
@@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int,
}
return cleaned, nil
}
// segmentRangeCleanupSettingKey marks the one-time removal of impossible
// skip ranges written by the older end_ms: 0 interpretation.
const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done"
// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。
//
// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是
// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集;
// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮,
// 没有任何保留价值,直接硬删。
//
// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。
func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
if c.Repo.Setting != nil {
if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
return 0, nil
}
}
res := c.Repo.DB.WithContext(ctx).Unscoped().
Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}).
Where("end_ms <= start_ms").
Delete(&model.MediaSegment{})
if res.Error != nil {
return 0, res.Error
}
cleaned := int(res.RowsAffected)
if c.Repo.Setting != nil {
if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil {
c.Log.Warn("mark segment range cleanup done failed", zap.Error(err))
}
}
if cleaned > 0 && c.Log != nil {
c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
return cleaned, nil
}
+2 -6
View File
@@ -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)
}
+31 -17
View File
@@ -41,15 +41,6 @@ const (
introDBMaxRetryDelay = 3 * time.Second
)
// IntroDBSpan is one resolved skip range, still in provider terms.
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
// end_ms: null for end credits); the caller resolves it against the duration.
type IntroDBSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
log *zap.Logger
@@ -113,7 +104,7 @@ type introDBResponse struct {
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]SegmentSpan, error) {
if s == nil || s.client == nil {
return nil, errors.New("introdb service nil")
}
@@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
}
}
// Source implements SegmentProvider.
func (s *IntroDBService) Source() string { return IntroDBSource }
// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。
func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if q.IsMovie {
return s.Fetch(ctx, q.TMDbID, 0, 0)
}
return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode)
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []IntroDBSpan
spans []SegmentSpan
err error
retryable bool
retryAfter time.Duration
@@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
// intro -> recap -> credits -> preview order so the player sees the earliest
// range first.
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
func parseIntroDBResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
@@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
{"credits", raw.Credits},
{"preview", raw.Preview},
}
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits))
for _, group := range groups {
for _, r := range group.ranges {
var start int64
@@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
if start < 0 {
start = 0
}
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
if end != 0 && end <= start {
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种
// 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零:
// end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法,
// 落成 0 作为哨兵值由客户端结合片长补齐;
// end_ms: 0 —— 「no segment」,即这一段不存在。
// 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}]
// 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮,
// 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。
if r.EndMs != nil && end <= start {
continue
}
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
// 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾
// 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头,
// 所以宁可不显示也不猜。
if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
}
return spans, nil
+160
View File
@@ -0,0 +1,160 @@
// Package service — IntroDB (https://introdb.app) segment client.
//
// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体,
// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象
// (而不是按类型分数组),片尾字段名是 outro 而不是 credits,
// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。
const IntroDBAppBaseURL = "https://api.introdb.app"
// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。
const IntroDBAppSource = "introdb"
const (
introDBAppTimeout = 8 * time.Second
introDBAppMaxBodySize = 1 << 20
)
// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。
type introDBAppSegment struct {
StartMs *int64 `json:"start_ms"`
EndMs *int64 `json:"end_ms"`
}
// introDBAppResponse 对应 GET /segments。
type introDBAppResponse struct {
IMDbID string `json:"imdb_id"`
MediaType string `json:"media_type"`
IsMovie bool `json:"is_movie"`
Season int `json:"season"`
Episode int `json:"episode"`
Intro *introDBAppSegment `json:"intro"`
Recap *introDBAppSegment `json:"recap"`
Outro *introDBAppSegment `json:"outro"`
PostCredits *introDBAppSegment `json:"post_credits"`
}
// IntroDBAppService 查询 IntroDB 的片段数据。
type IntroDBAppService struct {
log *zap.Logger
client *http.Client
baseURL string
}
// NewIntroDBAppService is the constructor. The client honours environment and
// OS proxy settings so it behaves like the other third-party API clients.
func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService {
return &IntroDBAppService{
log: log,
client: NewExternalHTTPClient(introDBAppTimeout),
baseURL: IntroDBAppBaseURL,
}
}
// SetBaseURL overrides the API root (tests, mirrors).
func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
}
return s
}
// Source implements SegmentProvider.
func (s *IntroDBAppService) Source() string { return IntroDBAppSource }
// FetchSegments implements SegmentProvider.
//
// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到
// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。
func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" {
return nil, nil
}
endpoint := s.segmentsURL(q)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
// 404 = 库里没有这部片,是正常的「无数据」,不是错误。
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize))
if err != nil {
return nil, err
}
return parseIntroDBAppResponse(body)
}
func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string {
values := url.Values{}
values.Set("imdb_id", strings.TrimSpace(q.IMDbID))
if q.IsMovie {
values.Set("is_movie", "true")
} else if q.Season > 0 && q.Episode > 0 {
values.Set("season", strconv.Itoa(q.Season))
values.Set("episode", strconv.Itoa(q.Episode))
}
return s.baseURL + "/segments?" + values.Encode()
}
// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。
//
// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的
// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。
func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBAppResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
}
groups := []struct {
kind string
seg *introDBAppSegment
}{
{"intro", raw.Intro},
{"recap", raw.Recap},
{"credits", raw.Outro},
{"preview", raw.PostCredits},
}
spans := make([]SegmentSpan, 0, len(groups))
for _, group := range groups {
if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil {
continue
}
start, end := *group.seg.StartMs, *group.seg.EndMs
if start < 0 {
start = 0
}
if end <= start {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
return spans, nil
}
+564
View File
@@ -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
}
+335
View File
@@ -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)
}
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
svc := NewMediaSegmentService(zap.NewNop(), repos)
m := &model.Media{
Base: model.Base{ID: "ep-1"},
Title: "Show",
Path: "/tv/show/s01e01.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
return svc, repos, m
}
func TestSetManualMarkWritesIntroBoundary(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatalf("set mark: %v", err)
}
rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("rows = %#v, want 1", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual {
t.Fatalf("row = %#v, want manual intro", rows[0])
}
if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 {
t.Fatalf("row = %#v, want 0-90000ms", rows[0])
}
}
func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil {
t.Fatal(err)
}
rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits {
t.Fatalf("rows = %#v, want only the credits mark to survive", rows)
}
}
func TestSetManualMarkRejectsInvalidRequests(t *testing.T) {
svc, _, m := newManualMarkFixture(t)
cases := []ManualMarkRequest{
{Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits
{Kind: "preview", PositionMs: 1000}, // 同上
{Kind: model.SegmentKindIntro, PositionMs: 0},
{Kind: model.SegmentKindIntro, PositionMs: -5},
}
for _, req := range cases {
if err := svc.SetManualMark(t.Context(), m, req); err == nil {
t.Fatalf("req %#v: want an error", req)
}
}
}
// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。
func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
sibling := &model.Media{
Base: model.Base{ID: "ep-2"},
Title: "Show",
Path: "/tv/show/s01e02.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 2,
}
other := &model.Media{
Base: model.Base{ID: "ep-3"},
Title: "Show",
Path: "/tv/show/s01e03.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 3,
}
if err := repos.DB.Create(sibling).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
// other 已经有自己的手动片头标记,不该被覆盖。
if err := svc.SetManualMark(ctx, other, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 45_000,
}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID)
if err != nil {
t.Fatal(err)
}
if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 {
t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows)
}
otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID)
if err != nil {
t.Fatal(err)
}
if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 {
t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows)
}
}
// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。
func TestManualMarkOutranksExternalSource(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{
MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource,
}}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
rows, err := svc.mergeForPlayback(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 {
t.Fatalf("rows = %#v, want the manual mark to win", rows)
}
}
+315 -29
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"errors"
"strings"
"time"
@@ -43,9 +44,20 @@ type SegmentView struct {
}
// ToSegmentViews 转换库内行为对外视图。
//
// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」
// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹
// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
out := make([]SegmentView, 0, len(rows))
for _, row := range rows {
openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview
if row.StartMs < 0 {
continue
}
if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) {
continue
}
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
}
return out
@@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
type MediaSegmentService struct {
log *zap.Logger
repo *repository.Container
introdb *IntroDBService
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
log *zap.Logger
repo *repository.Container
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
// prewarmGap 是预热两次请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
// providers 是已启用的外部片段源,顺序即注册顺序。
// 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。
providers []SegmentProvider
// imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。
imdb IMDbIDResolver
}
// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。
// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。
type IMDbIDResolver interface {
ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error)
}
// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb,
// 与播放器档案里的「自动跳过」是两件独立的事。
const SegmentSourceSettingKey = "segment.source"
// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。
const SegmentSourceOff = "off"
// SegmentSourceOptions 是设置页可选的源标识。
var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource}
// NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
return &MediaSegmentService{
@@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ
return s
}
// SetIntroDB wires the provider. Without it the service only reads cached rows.
// SetIntroDB wires the TheIntroDB provider and registers it as a source.
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
if s != nil {
s.introdb = p
s.registerProvider(p)
}
return s
}
// SetIntroDBApp wires the IntroDB (introdb.app) provider.
func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetTODb wires the TODb provider.
func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that
// only address media by IMDb id.
func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService {
if s != nil {
s.imdb = r
}
return s
}
// registerProvider adds a provider once, keeping registration order as the
// fallback order.
func (s *MediaSegmentService) registerProvider(p SegmentProvider) {
if s == nil || p == nil {
return
}
for _, existing := range s.providers {
if existing.Source() == p.Source() {
return
}
}
s.providers = append(s.providers, p)
}
// providerBySource returns the registered provider with that source tag.
func (s *MediaSegmentService) providerBySource(source string) SegmentProvider {
for _, p := range s.providers {
if p.Source() == source {
return p
}
}
return nil
}
// activeProvider 选出本次查询要用的源。
//
// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失;
// 显式配置成 off 才真正停用外部查询。
func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider {
if s == nil || len(s.providers) == 0 {
return nil
}
source := s.configuredSource(ctx)
if source == "" {
return s.providerBySource(IntroDBSource)
}
if source == SegmentSourceOff {
return nil
}
if p := s.providerBySource(source); p != nil {
return p
}
// 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源,
// 让功能继续可用,而不是静默失效。
return s.providerBySource(IntroDBSource)
}
// configuredSource reads the operator's source selection.
func (s *MediaSegmentService) configuredSource(ctx context.Context) string {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return ""
}
v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(v))
}
// SetProbe wires the async media probe (duration backfill for STRM). Without it
// open-ended credits still work once duration is known from elsewhere.
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
@@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
return s.communitySegments(ctx, m)
}
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
@@ -147,10 +263,15 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。
func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
provider := s.activeProvider(ctx)
// 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。
if provider == nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source())
if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
@@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
return nil, err
}
if ledger == nil || !ledgerFresh(ledger) {
if _, _, err := s.refresh(ctx, m); err != nil {
if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
logIntroDBFailure(s.log, 0, err)
}
@@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri
}
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
//
// 顺序体现用户意图的强度:
//
// manual 用户在当前集显式标记
// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库)
// 外部社区库 自动抓取,最容易被覆盖
//
// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个,
// 同级时按区间起点排序展示即可,不必让旧源压住新源。
func segmentSourcePriority(source string) int {
switch source {
case SegmentSourceManual:
return 3
case IntroDBSource:
return 2
case SegmentSourcePropagated:
return 2
case IntroDBSource, IntroDBAppSource, TODbSource:
return 1
default:
return 0
@@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) {
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
}
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。
//
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
if s.introdb == nil {
// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb),
// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) {
if s == nil || provider == nil || m == nil {
return nil, false, nil
}
tmdbID, season, episode := s.queryIDs(ctx, m)
if tmdbID <= 0 {
query, ok := s.segmentQuery(ctx, m, provider)
if !ok {
return nil, false, nil
}
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
@@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
defer cancel()
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
spans, err := provider.FetchSegments(fetchCtx, query)
if err != nil {
return nil, true, err
}
source := provider.Source()
rows := make([]model.MediaSegment, 0, len(spans))
for _, span := range spans {
rows = append(rows, model.MediaSegment{
@@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
Kind: span.Kind,
StartMs: span.StartMs,
EndMs: span.EndMs,
Source: IntroDBSource,
Source: source,
})
}
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil {
return nil, true, err
}
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
MediaID: m.ID,
Source: IntroDBSource,
Source: source,
FetchedAt: time.Now(),
Found: len(rows) > 0,
}); err != nil {
@@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
return rows, true, nil
}
// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
return s.refreshWithProvider(ctx, m, s.activeProvider(ctx))
}
// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID,
// 本次不必发请求。
func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) {
tmdbID, season, episode := s.queryIDs(ctx, m)
isMovie := season <= 0 && episode <= 0
q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie}
switch provider.Source() {
case IntroDBAppSource:
// IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。
if s.imdb == nil || tmdbID <= 0 {
return q, false
}
imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie)
if err != nil || imdbID == "" {
// 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。
// 返回 false 让本次跳过,但不写负缓存,下次还有机会。
s.debug("resolve imdb id failed", m.ID, err)
return q, false
}
q.IMDbID = imdbID
return q, true
default:
return q, tmdbID > 0
}
}
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
@@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
return false
}
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
// ManualMarkRequest 是「标记片头/片尾」的入参。
//
// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点,
// 标记片尾时它是片尾开始点。
type ManualMarkRequest struct {
Kind string
PositionMs int64
// Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。
Clear bool
}
// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。
var ErrInvalidManualMark = errors.New("invalid manual skip mark")
// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。
//
// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后,
// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据
// 会自然回落到优先队列里。
func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error {
if s == nil || s.repo == nil || m == nil || m.ID == "" {
return ErrInvalidManualMark
}
kind := normalizeManualMarkKind(req.Kind)
if kind == "" {
return ErrInvalidManualMark
}
if !req.Clear && req.PositionMs < 0 {
return ErrInvalidManualMark
}
rows := []model.MediaSegment{}
if !req.Clear {
if req.PositionMs <= 0 {
// 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。
return ErrInvalidManualMark
}
rows = append(rows, model.MediaSegment{
MediaID: m.ID,
SeriesID: m.SeriesID,
Kind: kind,
StartMs: manualMarkStart(kind),
EndMs: req.PositionMs,
Source: SegmentSourceManual,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil {
return err
}
if len(rows) > 0 {
s.propagateManualMarkToSeason(ctx, m, rows)
}
return nil
}
// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。
// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。
func normalizeManualMarkKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case model.SegmentKindIntro:
return model.SegmentKindIntro
case model.SegmentKindCredits:
return model.SegmentKindCredits
default:
return ""
}
}
// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户
// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。
func manualMarkStart(kind string) int64 { return 0 }
// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。
//
// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。
// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分
// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标
// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的
// 新标记),两种顺序都会得出错误结果。
func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 {
return
}
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
if err != nil {
s.debug("list season siblings for manual mark failed", m.ID, err)
return
}
for i := range siblings {
sib := &siblings[i]
// 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。
explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual)
if err != nil {
s.debug("list sibling manual segments failed", sib.ID, err)
continue
}
if hasSegmentKind(explicit, rows[0].Kind) {
continue
}
copied := make([]model.MediaSegment, 0, len(rows))
for _, row := range rows {
copied = append(copied, model.MediaSegment{
MediaID: sib.ID,
SeriesID: sib.SeriesID,
Kind: row.Kind,
StartMs: row.StartMs,
EndMs: row.EndMs,
Source: SegmentSourcePropagated,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil {
s.debug("propagate manual mark failed", sib.ID, err)
}
}
}
// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
//
// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的
// 账本也不再需要刷新。
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil || s.introdb == nil {
if s == nil || s.repo == nil || len(s.providers) == 0 {
return 0, nil
}
provider := s.activeProvider(ctx)
if provider == nil {
return 0, nil
}
if limit <= 0 {
@@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
now = s.now()
}
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
)
if err != nil {
return 0, err
@@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
return attempted, err
}
m := &candidates[i]
_, did, err := s.refresh(ctx, m)
_, did, err := s.refreshWithProvider(ctx, m, provider)
if err != nil {
logIntroDBFailure(s.log, m.TMDbID, err)
}
+7 -4
View File
@@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
}
}
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
// 优先级体现「用户意图强度」:manual > propagated > 社区库。
// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。
func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) {
rows := []model.MediaSegment{
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
@@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing
}
got := preferSegmentsBySource(rows)
if len(got) != 2 {
t.Fatalf("got %#v, want manual intro + introdb credits", got)
t.Fatalf("got %#v, want manual intro + propagated credits", got)
}
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
t.Fatalf("intro = %#v, want manual", got[0])
}
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
t.Fatalf("credits = %#v, want theintrodb", got[1])
// 片尾没有 manual,propagated 应当压过社区库。
if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 {
t.Fatalf("credits = %#v, want propagated", got[1])
}
}
-7
View File
@@ -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 != "" {
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -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 {
+46
View File
@@ -0,0 +1,46 @@
// Package service — 片头/片尾片段的外部提供方抽象。
//
// 目前有三个社区库可用,它们对「媒体身份」的要求各不相同:
//
// TheIntroDB TMDb id(服务端自己映射到 TVDB)
// IntroDB 只有 IMDb id(用 is_movie 区分电影/剧集)
// TODb TMDb id + 平台类型(tmdb_id_tv / tmdb_id_movie),秒级时间轴
//
// 播放链路只关心「一段可跳过的区间」,所以这里把三者收敛成同一个
// SegmentProvider 接口和 SegmentQuery 入参,上层按设置项挑选其一。
package service
import "context"
// SegmentSpan 是一个已解析的可跳过区间,单位为毫秒。
//
// EndMs == 0 是哨兵值,表示「一直到片尾」:只有片尾(credits/preview)可能
// 出现这种区间,由客户端结合媒体总时长补齐。片头/回顾不允许开放区间,
// 拿不到结束点就整段丢弃——否则会得到一个覆盖全片的区间。
type SegmentSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBSpan 保留旧名字,避免既有调用方和测试大范围改名。
type IntroDBSpan = SegmentSpan
// SegmentQuery 是一次片段查询的身份参数。不同提供方按需取用其中一部分。
type SegmentQuery struct {
TMDbID int
IMDbID string
Season int
Episode int
// IsMovie 为 true 时 Season/Episode 无意义。
IsMovie bool
}
// SegmentProvider 是一个外部片段数据源。
type SegmentProvider interface {
// Source 返回写入 media_segments.source 的标识,也是缓存账本的键。
Source() string
// FetchSegments 返回该媒体已知的可跳过区间。查不到不是错误(返回空切片),
// 只有请求本身失败才返回 error——调用方据此决定是否保留旧缓存。
FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error)
}
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
)
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)对「没有数值」有两种
// 写法,含义完全不同:end_ms: null = 一直到片尾;end_ms: 0 = 这一段不存在。
// 实测约三分之一的剧集会返回 {"start_ms":null,"end_ms":0} 的 intro 占位。
const (
// 真实返回:intro 是占位、credits 到片尾。
introDBNoSegmentPayload = `{"tvdb_id":73762,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":null,"end_ms":0}],"credits":[{"start_ms":2574000,"end_ms":null}]}`
// 整集都是占位:recap/credits 都是 (0,0)。
introDBAllPlaceholderPayload = `{"tvdb_id":375921,"type":"tv","season":1,"episode":1,"recap":[{"start_ms":0,"end_ms":0}],"credits":[{"start_ms":0,"end_ms":0}]}`
)
// 把 end_ms: 0 当成「到片尾」会让 intro 变成 [0, 片长],整集都是片头:
// 一开播就弹跳过按钮,开了自动跳过直接跳到片尾。
func TestParseIntroDBResponseDropsZeroEndPlaceholders(t *testing.T) {
spans, err := parseIntroDBResponse([]byte(introDBNoSegmentPayload))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 {
t.Fatalf("spans = %#v, want only the credits range", spans)
}
if spans[0].Kind != "credits" || spans[0].StartMs != 2_574_000 || spans[0].EndMs != 0 {
t.Fatalf("credits span = %#v, want start 2574000 and open end", spans[0])
}
}
func TestParseIntroDBResponseDropsAllPlaceholderSegments(t *testing.T) {
spans, err := parseIntroDBResponse([]byte(introDBAllPlaceholderPayload))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 0 {
t.Fatalf("spans = %#v, want none for all-placeholder payload", spans)
}
}
// 片头/回顾不允许开放结束点:即使数据源给了 null,也不能补齐成「一直到片尾」。
func TestParseIntroDBResponseRejectsOpenEndedIntro(t *testing.T) {
body := `{"tvdb_id":1,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":1000,"end_ms":null}]}`
spans, err := parseIntroDBResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 0 {
t.Fatalf("spans = %#v, want an open-ended intro to be dropped", spans)
}
}
// IntroDB 的响应把区间给成对象而不是数组,且片尾字段叫 outro。
func TestParseIntroDBAppResponseMapsKinds(t *testing.T) {
body := `{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,"season":1,"episode":1,
"intro":{"start_ms":437000,"end_ms":531000},
"recap":null,
"outro":{"start_ms":3431000,"end_ms":3500000},
"post_credits":{"start_ms":3600000,"end_ms":3610000}}`
spans, err := parseIntroDBAppResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
want := []struct {
kind string
start int64
end int64
}{
{"intro", 437_000, 531_000},
{"credits", 3_431_000, 3_500_000},
{"preview", 3_600_000, 3_610_000},
}
if len(spans) != len(want) {
t.Fatalf("spans = %#v, want %d", spans, len(want))
}
for i, w := range want {
if spans[i].Kind != w.kind || spans[i].StartMs != w.start || spans[i].EndMs != w.end {
t.Fatalf("span[%d] = %#v, want %s %d-%d", i, spans[i], w.kind, w.start, w.end)
}
}
}
// IntroDB 只认 IMDb id:没有 IMDb id 时不能退化成用 TMDb id 拼请求,
// 那只会在服务端拿到 400,被误记成「查询失败」而保留旧缓存。
func TestIntroDBAppFetchSkipsWithoutIMDbID(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
spans, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 1396, Season: 1, Episode: 1})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(spans) != 0 || calls != 0 {
t.Fatalf("spans = %#v calls = %d, want no request without an imdb id", spans, calls)
}
}
func TestIntroDBAppFetchSendsIMDbAndSeasonEpisode(t *testing.T) {
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,
"intro":{"start_ms":1000,"end_ms":2000}}`))
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{
IMDbID: "tt0903747", Season: 2, Episode: 3,
}); err != nil {
t.Fatalf("fetch: %v", err)
}
for _, want := range []string{"imdb_id=tt0903747", "season=2", "episode=3"} {
if !strings.Contains(gotQuery, want) {
t.Fatalf("query = %q, want it to contain %q", gotQuery, want)
}
}
}
// 电影要用 is_movie,且不能带季集参数。
func TestIntroDBAppFetchUsesIsMovieForMovies(t *testing.T) {
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`{"imdb_id":"tt1375666","is_movie":true}`))
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{IMDbID: "tt1375666", IsMovie: true}); err != nil {
t.Fatalf("fetch: %v", err)
}
if !strings.Contains(gotQuery, "is_movie=true") {
t.Fatalf("query = %q, want is_movie=true", gotQuery)
}
if strings.Contains(gotQuery, "season=") || strings.Contains(gotQuery, "episode=") {
t.Fatalf("query = %q, want no season/episode for a movie", gotQuery)
}
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// stubIMDbResolver 返回固定 IMDb id,用于验证 IntroDB 的 id 解析链路。
type stubIMDbResolver struct {
id string
calls int32
}
func (s *stubIMDbResolver) ResolveIMDbID(_ context.Context, _ int, _ bool) (string, error) {
atomic.AddInt32(&s.calls, 1)
return s.id, nil
}
func TestSegmentSourceOffSkipsExternalLookup(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, SegmentSourceOff); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-off"}, Path: "/movies/off.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
t.Fatalf("rows = %#v calls = %d, want no lookup when the source is off", rows, calls)
}
}
// 默认(未配置设置项)必须保持历史行为:用 TheIntroDB。
func TestSegmentSourceDefaultsToTheIntroDB(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
m := &model.Media{Base: model.Base{ID: "mv-default"}, Path: "/movies/default.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 1 || atomic.LoadInt32(&calls) != 1 {
t.Fatalf("rows = %#v calls = %d, want the default provider to run", rows, calls)
}
}
// 切到 TODb 后,TheIntroDB 不应再被调用,写入的 source 也要跟着变。
func TestSegmentSourceSwitchesToTODb(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var introCalls, todbCalls int32
introServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&introCalls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer introServer.Close()
todbServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&todbCalls, 1)
_, _ = w.Write([]byte(`{"items":[{"marker":[{"marker_type":"intro","time_start":5,"time_end":30}]}]}`))
}))
defer todbServer.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(introServer.URL)).
SetTODb(NewTODbService(zap.NewNop()).SetBaseURL(todbServer.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, TODbSource); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-todb"}, Path: "/movies/todb.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if atomic.LoadInt32(&introCalls) != 0 {
t.Fatalf("theintrodb was called %d times, want 0 after switching source", introCalls)
}
if atomic.LoadInt32(&todbCalls) != 1 {
t.Fatalf("todb calls = %d, want 1", todbCalls)
}
if len(rows) != 1 || rows[0].Source != TODbSource {
t.Fatalf("rows = %#v, want one row tagged %s", rows, TODbSource)
}
if rows[0].StartMs != 5000 || rows[0].EndMs != 30000 {
t.Fatalf("row = %#v, want 5000-30000ms", rows[0])
}
}
// 未知的源标识(降级回滚残留)要退回默认源,而不是静默失效。
func TestSegmentSourceFallsBackForUnknownValue(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, "no-such-source"); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-unknown"}, Path: "/movies/u.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if _, err := svc.ListForPlayback(t.Context(), m); err != nil {
t.Fatalf("list: %v", err)
}
if atomic.LoadInt32(&calls) != 1 {
t.Fatalf("calls = %d, want the default provider to still run", calls)
}
}
// IntroDB 需要 IMDb id;解析不出来时必须跳过请求,且不能写负缓存
// (否则元数据补齐之后也永远不会再查)。
func TestSegmentIntroDBSkipsWhenIMDbUnavailable(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDBApp(NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)).
SetIMDbResolver(&stubIMDbResolver{id: ""})
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, IntroDBAppSource); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-noimdb"}, Path: "/movies/noimdb.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
t.Fatalf("rows = %#v calls = %d, want no request without an imdb id", rows, calls)
}
ledger, err := repos.MediaSegment.GetFetch(t.Context(), "mv-noimdb", IntroDBAppSource)
if err != nil {
t.Fatal(err)
}
if ledger != nil {
t.Fatalf("ledger = %#v, want no negative cache entry", ledger)
}
}
+8 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"strings"
"time"
"go.uber.org/zap"
@@ -135,8 +134,14 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
// 片头/片尾片段:播放时按需向 TheIntroDB 补齐并落库,供下次直接命中。
b.c.Segments = NewMediaSegmentService(b.log, b.repos).SetIntroDB(NewIntroDBService(b.log))
// 片头/片尾片段:播放时按需向社区库补齐并落库,供下次直接命中。
// 三个源都注册,具体用哪个由设置项 segment.source 决定(默认 theintrodb)。
b.c.Segments = NewMediaSegmentService(b.log, b.repos).
SetIntroDB(NewIntroDBService(b.log)).
SetIntroDBApp(NewIntroDBAppService(b.log)).
SetTODb(NewTODbService(b.log)).
// IntroDB 只认 IMDb id,需要经 TMDb 的 external_ids 解析。
SetIMDbResolver(b.c.TMDb)
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
@@ -222,9 +227,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)。
+33
View File
@@ -20,6 +20,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
@@ -133,3 +135,34 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
}
return json.NewDecoder(resp.Body).Decode(out)
}
// ResolveIMDbID 用 TMDb 的 external_ids 端点把 TMDb 条目解析成 IMDb id。
//
// 这是为只认 IMDb id 的片段源(IntroDB)准备的。结果不做本地缓存:TMDb 的
// external_ids 响应很小,而片段查询本身已经有 30 天/24 小时的正负缓存兜底,
// 再叠一层只会引入失效问题。
//
// TMDb 未配置或上游不可达时返回空字符串与 error,调用方据此跳过该源,
// 而不是把所有媒体都记成「查不到」。
func (t *TMDbProvider) ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error) {
if t == nil || t.cfg == nil || tmdbID <= 0 {
return "", nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return "", nil
}
mediaType := "tv"
if isMovie {
mediaType = "movie"
}
u := t.resolveBaseURL(ctx) + "/" + mediaType + "/" + fmt.Sprint(tmdbID) +
"/external_ids?api_key=" + url.QueryEscape(apiKey)
var r struct {
IMDbID string `json:"imdb_id"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return "", err
}
return strings.TrimSpace(r.IMDbID), nil
}
+213
View File
@@ -0,0 +1,213 @@
// Package service — TODb (TheOtherDB) segment client.
//
// TODb 的播放资料库按「片源」组织:同一个 TMDB 条目可能有爱奇艺、腾讯等多个
// 版本的 marker,每个版本自带 version_runtime(该片源的时长)。因此它的响应是
// 一个 items 数组,而不是单一区间集合。
//
// 接口是 POST + JSON body(GET 会返回 405),平台用 platform_type 表达:
//
// tmdb_id_tv + season_number / episode_number
// tmdb_id_movie 无季集
//
// marker 的时间轴是**秒**(time_start / time_end),不是毫秒。
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.uber.org/zap"
)
// TODbBaseURL 是公开只读端点。
const TODbBaseURL = "https://playback.theotherdb.org/api/metadata"
// TODbSource 写入 media_segments.source。
const TODbSource = "todb"
const (
todbTimeout = 10 * time.Second
todbMaxBodySize = 1 << 20
// todbPageSize 一次取多少条版本记录。社区库大多数条目只有一个片源,
// 但同一部剧的各平台版本可能都有人标,取多一点可以避免漏掉匹配时长的那个。
todbPageSize = 10
)
// todbRequest 是 POST /api/metadata 的请求体。
type todbRequest struct {
PlatformType string `json:"platform_type"`
PlatformValue string `json:"platform_value"`
SeasonNumber int `json:"season_number,omitempty"`
EpisodeNumber int `json:"episode_number,omitempty"`
Includes []string `json:"includes"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// todbMarker 是单个标记点。time_start/time_end 单位是秒。
type todbMarker struct {
MarkerType string `json:"marker_type"`
TimeStart *float64 `json:"time_start"`
TimeEnd *float64 `json:"time_end"`
}
// todbItem 是一个片源版本及其 marker。
type todbItem struct {
VersionID int `json:"version_id"`
VersionName string `json:"version_name"`
VersionRuntime *int `json:"version_runtime"`
VideoType string `json:"video_type"`
Marker []todbMarker `json:"marker"`
}
type todbResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
Items []todbItem `json:"items"`
}
// TODbService 查询 TODb 的片段数据。
type TODbService struct {
log *zap.Logger
client *http.Client
baseURL string
pageSize int
}
// NewTODbService is the constructor.
func NewTODbService(log *zap.Logger) *TODbService {
return &TODbService{
log: log,
client: NewExternalHTTPClient(todbTimeout),
baseURL: TODbBaseURL,
pageSize: todbPageSize,
}
}
// SetBaseURL overrides the API endpoint (tests, mirrors).
func (s *TODbService) SetBaseURL(base string) *TODbService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimSpace(base)
}
return s
}
// Source implements SegmentProvider.
func (s *TODbService) Source() string { return TODbSource }
// FetchSegments implements SegmentProvider.
//
// 返回时会把多个版本的 marker 合并:同 kind 取第一个出现的区间。TODb 的片源
// 时长各不相同,严格按 version_runtime 匹配当前媒体需要可靠的总时长,而片段
// 查询经常跑在时长还不知道的时候(STRM 媒体),所以这里不做版本筛选——宁可
// 给一个近似位置,也好过整段缺失。真正需要精度的场景由 manual 标记覆盖。
func (s *TODbService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || q.TMDbID <= 0 {
return nil, nil
}
payload := todbRequest{
PlatformValue: fmt.Sprint(q.TMDbID),
Includes: []string{"marker"},
Page: 1,
PageSize: s.pageSize,
}
if q.IsMovie {
payload.PlatformType = "tmdb_id_movie"
} else {
payload.PlatformType = "tmdb_id_tv"
payload.SeasonNumber = q.Season
payload.EpisodeNumber = q.Episode
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("todb: unexpected status %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, todbMaxBodySize))
if err != nil {
return nil, err
}
return parseTODbResponse(raw)
}
// parseTODbResponse 把各片源的 marker 合并成 spans。
//
// marker_type 的映射沿用报告里的规则:intro -> intro,credits/post_credits ->
// credits(片尾),其余(chapter 等章节标记)忽略——章节标题没有可靠的语义,
// 当成片头会让跳过点漂到任意位置。
func parseTODbResponse(body []byte) ([]SegmentSpan, error) {
var raw todbResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse todb json: %w", err)
}
seen := make(map[string]bool, 4)
spans := make([]SegmentSpan, 0, 4)
for _, item := range raw.Items {
for _, marker := range item.Marker {
kind := todbMarkerKind(marker.MarkerType)
if kind == "" || seen[kind] {
continue
}
if marker.TimeStart == nil || marker.TimeEnd == nil {
continue
}
startMs := int64(*marker.TimeStart * 1000)
endMs := int64(*marker.TimeEnd * 1000)
if startMs < 0 {
startMs = 0
}
// 结束点缺失或非正表示「到片尾」,只有这样才是合法的开放区间;
// 与 TheIntroDB 不同,TODb 用 0 而不是 null 表示没标结束点,
// 所以这里把 <= start 的片尾当作开放区间。
if endMs <= startMs {
if kind != "credits" && kind != "preview" {
continue
}
endMs = 0
}
spans = append(spans, SegmentSpan{Kind: kind, StartMs: startMs, EndMs: endMs})
seen[kind] = true
}
}
return spans, nil
}
// todbMarkerKind 把 TODb 的 marker_type 映射成库内 kind。
func todbMarkerKind(markerType string) string {
switch strings.ToLower(strings.TrimSpace(markerType)) {
case "intro":
return "intro"
case "recap":
return "recap"
case "credits", "post_credits", "outro":
return "credits"
case "preview":
return "preview"
default:
return ""
}
}
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
)
// TODb 的接口是 POST + JSON body(GET 返回 405),平台类型用 tmdb_id_tv /
// tmdb_id_movie 表达,marker 的时间轴是秒。
func TestTODbFetchSendsPostWithTMDBPlatform(t *testing.T) {
var body map[string]any
var method string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"page":1,"page_size":10,"has_more":false,"items":[]}`))
}))
defer server.Close()
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 226045, Season: 1, Episode: 1}); err != nil {
t.Fatalf("fetch: %v", err)
}
if method != http.MethodPost {
t.Fatalf("method = %s, want POST", method)
}
if body["platform_type"] != "tmdb_id_tv" || body["platform_value"] != "226045" {
t.Fatalf("body = %#v, want tmdb_id_tv/226045", body)
}
if body["season_number"] != float64(1) || body["episode_number"] != float64(1) {
t.Fatalf("body = %#v, want season/episode 1", body)
}
}
func TestTODbFetchUsesMoviePlatform(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"items":[]}`))
}))
defer server.Close()
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 83533, IsMovie: true}); err != nil {
t.Fatalf("fetch: %v", err)
}
if body["platform_type"] != "tmdb_id_movie" {
t.Fatalf("platform_type = %v, want tmdb_id_movie", body["platform_type"])
}
if _, ok := body["season_number"]; ok {
t.Fatalf("body = %#v, want no season_number for a movie", body)
}
}
// 实测返回:marker 的时间是秒,marker_type 为 intro。
func TestParseTODbResponseConvertsSecondsToMilliseconds(t *testing.T) {
body := `{"page":1,"page_size":3,"has_more":false,"items":[
{"version_id":1192,"version_name":"爱奇艺","version_runtime":1300,"video_type":"tv",
"marker":[{"marker_id":1588,"marker_type":"intro","time_start":0,"time_end":27}]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 {
t.Fatalf("spans = %#v, want 1", spans)
}
if spans[0].Kind != "intro" || spans[0].StartMs != 0 || spans[0].EndMs != 27_000 {
t.Fatalf("span = %#v, want intro 0-27000ms", spans[0])
}
}
func TestParseTODbResponseMapsMarkerTypes(t *testing.T) {
body := `{"items":[{"marker":[
{"marker_type":"intro","time_start":10,"time_end":30},
{"marker_type":"credits","time_start":100,"time_end":120},
{"marker_type":"post_credits","time_start":130,"time_end":140},
{"marker_type":"chapter","time_start":5,"time_end":6}
]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
// chapter 被忽略;post_credits 与 credits 同为片尾,同 kind 只保留第一个。
if len(spans) != 2 {
t.Fatalf("spans = %#v, want intro + credits", spans)
}
if spans[0].Kind != "intro" || spans[1].Kind != "credits" {
t.Fatalf("kinds = %s/%s, want intro/credits", spans[0].Kind, spans[1].Kind)
}
}
// 片尾的结束点缺失或非正表示「到片尾」,是合法的开放区间;片头不行。
func TestParseTODbResponseAllowsOpenEndedCreditsOnly(t *testing.T) {
body := `{"items":[{"marker":[
{"marker_type":"intro","time_start":10,"time_end":10},
{"marker_type":"credits","time_start":100,"time_end":100}
]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 || spans[0].Kind != "credits" || spans[0].EndMs != 0 {
t.Fatalf("spans = %#v, want only an open-ended credits", spans)
}
}
+6
View File
@@ -272,6 +272,9 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
input.Quality = quality
t.maybeFillDuration(ctx, m, input)
settings := ffmpegArgSettingsFromConfig(cfg, quality)
// 「原画」档位遇到浏览器可解的 H.264 源时只换封装,避免慢机器上重编码
// 追不上播放(两核软件编码实测 0.1~0.2 倍速)。其它档位要改分辨率,仍重编码。
input.CopyVideo = t.remuxEligible(ctx, m, quality, subtitleStream)
jobEncoder := settings.encoder
if settings.noScale || subtitleStream >= 0 {
jobEncoder = ""
@@ -312,6 +315,9 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
if subtitleStream >= 0 {
jobEncoder = "software (subtitle burn-in)"
}
if input.CopyVideo {
jobEncoder = remuxEncoderLabel
}
job := &hlsJob{
jobKey: jobKey,
mediaID: mediaID,
+33 -4
View File
@@ -16,6 +16,9 @@ type transcodeInput struct {
StartSec float64
SubtitleStream *int
Quality *LocalHLSQuality
// CopyVideo 为 true 时视频流只换封装(-c:v copy),不重编码。
// 仅当源视频是浏览器可解的老三样(H.264/8bit/4:2:0)且不需要缩放时启用。
CopyVideo bool
}
type ffmpegArgSettings struct {
@@ -52,6 +55,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
settings.encoder = ""
}
copyVideo := input.CopyVideo && input.SubtitleStream == nil
video := ffmpegVideoPlanForSettings(settings)
if input.SubtitleStream != nil {
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
@@ -60,13 +64,17 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
settings.encoder = ""
video = ffmpegVideoPlanForSettings(settings)
}
if copyVideo {
// 直通:不做缩放、不挂滤镜、不选编码器,整条视频流原样塞进 TS。
video = ffmpegVideoPlan{codec: "copy"}
}
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
// seek point at 1x wall-clock before emitting the first HLS segment.
realtime := settings.realtime && input.StartSec <= 0.05
args := baseFFmpegArgs(video.preInput, realtime)
args = appendInputAndVideoArgs(args, input, settings, video)
args = appendOutputHLSArgs(args, settings, segments, playlist)
args = appendInputAndVideoArgs(args, input, settings, video, copyVideo)
args = appendOutputHLSArgs(args, settings, segments, playlist, copyVideo)
return args
}
@@ -161,7 +169,7 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
return args
}
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan, copyVideo bool) []string {
args = append(args, ffmpegHTTPInputArgs(input)...)
ss := ""
if input.StartSec > 0.05 {
@@ -172,6 +180,11 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
// scrubbing; CDNs with Range support jump via demuxer seek instead.
if ss != "" {
args = append(args, "-ss", ss)
if copyVideo {
// 复制视频流时 ffmpeg 不能解码,精确 seek 会把关键帧之前的分组丢掉,
// 输出从 P 帧开始(花屏)。退到前一个关键帧,最多提前一两秒。
args = append(args, "-noaccurate_seek")
}
}
args = append(args, "-i", input.Source)
if input.SubtitleStream != nil {
@@ -197,7 +210,23 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
return args
}
func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string) []string {
func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string, copyVideo bool) []string {
if copyVideo {
// 视频是复制进来的,编码参数一律不适用;只保留音频转 AAC 与切片参数。
// -avoid_negative_ts 让带 B 帧的源在 -ss 之后不会出现负时间戳。
return append(args,
"-avoid_negative_ts", "make_zero",
"-c:a", "aac",
"-ar", "48000",
"-b:a", "128k",
"-ac", "2",
"-f", "hls",
"-hls_time", fmt.Sprintf("%d", settings.segmentSeconds),
"-hls_list_size", "0",
"-hls_segment_filename", segments,
playlist,
)
}
return append(args,
"-pix_fmt", "yuv420p",
"-b:v", settings.bitrate,
+102
View File
@@ -0,0 +1,102 @@
package service
import (
"context"
"encoding/json"
"strings"
"github.com/truewhile/MeBox/internal/model"
)
// 蓝光原盘(m2ts,40 Mbps 级别)在两核小机器上重编码只有 0.1~0.2 倍速,HLS
// 永远追不上播放,播放器等不到切片就报「转码失败」。当源视频本身就是浏览器
// 能解的 H.264 / 8bit / 4:2:0,且用户选的是「原画」档位(不改分辨率)时,
// 这里改走只换封装(-c:v copy)的直通路径:视频不重编码,音频转 AAC,
// CPU 占用接近于零,起播时间从几十秒降到几秒。
//
// 直通只对「原画」生效。1080P/720P/480P 档位意味着要改分辨率,必须重新编码;
// 那类请求仍然按原来的 libx264 路径处理(慢,但码率可控)。
// remuxEncoderLabel 是直通任务在日志与任务面板里的标签。
const remuxEncoderLabel = "remux"
// remuxEligible 判断这次请求能否复制视频流而不是重编码。
func (t *TranscoderService) remuxEligible(ctx context.Context, m *model.Media, quality *LocalHLSQuality, subtitleStream int) bool {
if t == nil || t.repo == nil || m == nil {
return false
}
// 烧录图形字幕要把字幕叠加进画面,必须解码+重编码。
if subtitleStream >= 0 {
return false
}
if !remuxAppliesToQuality(quality) {
return false
}
probe, err := t.repo.MediaProbe.Get(ctx, m.ID)
if err != nil || probe == nil {
// 没有探测缓存就无法确认档次/像素格式,保守走重编码。
return false
}
return remuxCopyableHeight(probe) > 0
}
// remuxAppliesToQuality 只放行「原画」档位(Height 为 0,即不缩放)。
// quality 为 nil 表示调用方没有指定档位(走全局 max_height),同样不直通,
// 否则会绕过配置的分辨率上限给出一个远超预期的码流。
func remuxAppliesToQuality(quality *LocalHLSQuality) bool {
return quality != nil && quality.Height <= 0
}
// remuxCopyableHeight 在源视频可直接复制进 HLS 时返回它的高度,否则返回 0。
func remuxCopyableHeight(probe *model.MediaProbe) int {
if probe == nil || probe.Height <= 0 {
return 0
}
if !strings.EqualFold(strings.TrimSpace(probe.VideoCodec), "h264") {
return 0
}
if !browserSafeH264Payload(probe.Payload) {
return 0
}
return probe.Height
}
// probePayloadStreams 只解析落库 payload 里与直通判断有关的字段。
type probePayloadStreams struct {
Streams []struct {
Type string `json:"type"`
Profile string `json:"profile"`
PixFmt string `json:"pix_fmt"`
} `json:"streams"`
}
// browserSafeH264Payload 只放行 8bit 4:2:0 的 Baseline/Main/High。
// Hi10P(High 10)和 4:2:2 母带浏览器解不出来,复制过去只会黑屏或无声。
func browserSafeH264Payload(payload string) bool {
payload = strings.TrimSpace(payload)
if payload == "" {
// 老数据没有 payload:已经有 codec + 分辨率,按 8bit 常规情况处理。
return true
}
var parsed probePayloadStreams
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
return false
}
for _, stream := range parsed.Streams {
if !strings.EqualFold(strings.TrimSpace(stream.Type), "video") {
continue
}
switch strings.ToLower(strings.TrimSpace(stream.Profile)) {
case "", "constrained baseline", "baseline", "main", "high":
default:
return false
}
switch strings.ToLower(strings.TrimSpace(stream.PixFmt)) {
case "", "yuv420p", "yuvj420p":
default:
return false
}
return true
}
return true
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// 直通档位必须真的把视频流复制过去:一旦又带上 libx264 / -vf / 码率参数,
// 慢机器上的「原画」就还是几十秒起播。
func TestBuildFFmpegArgsRemuxCopiesVideoStream(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = true
cfg.Transcoder.Threads = 2
cfg.Transcoder.MaxHeight = 720
quality, ok := LocalHLSQualityByID("source")
if !ok {
t.Fatal("source quality missing")
}
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example/x.m2ts",
Headers: map[string]string{"User-Agent": "MeBox-115-GoClient/1.0"},
StartSec: 52.4,
Quality: &quality,
CopyVideo: true,
}, "/cache/hls/k/run-1/index.m3u8", "/cache/hls/k/run-1/seg_%05d.ts")
joined := strings.Join(args, " ")
for _, want := range []string{
"-c:v copy",
"-c:a aac",
"-ar 48000",
"-b:a 128k",
"-ac 2",
"-avoid_negative_ts make_zero",
"-ss 52.400",
"-noaccurate_seek",
"-hls_time 4",
"-hls_segment_filename /cache/hls/k/run-1/seg_%05d.ts",
} {
if !strings.Contains(joined, want) {
t.Errorf("remux args missing %q: %s", want, joined)
}
}
for _, unwanted := range []string{
"libx264",
"-vf ",
"-b:v",
"-maxrate",
"-bufsize",
"-force_key_frames",
"-preset",
"-threads",
"-pix_fmt",
} {
if strings.Contains(joined, unwanted) {
t.Errorf("remux args must not contain %q: %s", unwanted, joined)
}
}
// 中途起播不能加 -re,否则会先按 1 倍速爬到目标位置才出第一个切片。
if strings.Contains(joined, " -re ") {
t.Errorf("mid-file remux must not use -re: %s", joined)
}
}
// 烧录图形字幕要把字幕叠进画面,必须解码重编码,不能被直通标志绕过。
func TestBuildFFmpegArgsRemuxIgnoredForSubtitleBurnIn(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
quality, _ := LocalHLSQualityByID("source")
subtitle := 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/media/ep.mkv",
Quality: &quality,
CopyVideo: true,
SubtitleStream: &subtitle,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if strings.Contains(joined, "-c:v copy") {
t.Fatalf("subtitle burn-in must not copy the video stream: %s", joined)
}
if !strings.Contains(joined, "libx264") || !strings.Contains(joined, "-filter_complex") {
t.Fatalf("subtitle burn-in should re-encode: %s", joined)
}
if strings.Contains(joined, "-noaccurate_seek") {
t.Fatalf("encoded path should keep accurate seek: %s", joined)
}
}
// 只有「原画」档位(不缩放)可以直通;其它档位要改分辨率。
func TestRemuxAppliesToQuality(t *testing.T) {
if remuxAppliesToQuality(nil) {
t.Fatal("nil quality must not remux: it falls back to the configured max height")
}
source, _ := LocalHLSQualityByID("source")
if !remuxAppliesToQuality(&source) {
t.Fatal("source quality should remux")
}
for _, id := range []string{"1080", "720", "480"} {
quality, ok := LocalHLSQualityByID(id)
if !ok {
t.Fatalf("quality %s missing", id)
}
if remuxAppliesToQuality(&quality) {
t.Fatalf("quality %s must re-encode", id)
}
}
}
// 只有 8bit 4:2:0 的 H.264 能安全复制;Hi10P / 4:2:2 / HEVC 复制过去浏览器黑屏。
func TestRemuxCopyableHeight(t *testing.T) {
const blurayPayload = `{"container":"mpegts","duration_sec":1451,"bit_rate":42609934,"streams":[{"index":0,"type":"video","codec":"h264","profile":"High","width":1920,"height":1080,"pix_fmt":"yuv420p","frame_rate":"23.976"},{"index":1,"type":"audio","codec":"pcm_bluray","channels":2}]}`
cases := []struct {
name string
probe *model.MediaProbe
want int
}{
{
name: "bluray h264 8bit 420",
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: blurayPayload},
want: 1080,
},
{
name: "no payload falls back to codec and height",
probe: &model.MediaProbe{VideoCodec: "h264", Height: 720},
want: 720,
},
{
name: "hevc cannot be copied into browser hls",
probe: &model.MediaProbe{VideoCodec: "hevc", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"Main 10","pix_fmt":"yuv420p10le"}]}`},
want: 0,
},
{
name: "hi10p h264 is unplayable in browsers",
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"High 10","pix_fmt":"yuv420p10le"}]}`},
want: 0,
},
{
name: "4:2:2 h264 is unplayable in browsers",
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"High 4:2:2","pix_fmt":"yuv422p"}]}`},
want: 0,
},
{
name: "missing probe",
probe: nil,
want: 0,
},
{
name: "unknown height",
probe: &model.MediaProbe{VideoCodec: "h264", Height: 0, Payload: blurayPayload},
want: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := remuxCopyableHeight(tc.probe); got != tc.want {
t.Fatalf("remuxCopyableHeight() = %d, want %d", got, tc.want)
}
})
}
}
File diff suppressed because one or more lines are too long
+10
View File
@@ -54,6 +54,16 @@ export const playbackAPI = {
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
.then((r) => r.data),
// 手动标记:把当前位置记为片头结束 / 片尾开始(clear 则清除)。服务端会按季
// 传播到同季其它尚未标记的集,并返回合并后的完整片段列表。
markSegment: (
mediaId: string,
payload: { kind: 'intro' | 'credits'; position_ms?: number; clear?: boolean },
) =>
api
.put<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`, payload)
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data),
+14 -6
View File
@@ -290,6 +290,8 @@ export function PlayerControls({
const vr360ActiveRef = useRef(Boolean(vr360))
/** 画面是否已锁住(VR 锁)。事件回调里读它,避免闭包读到过期值。 */
const uiLockedRef = useRef(uiLocked)
/** 控制栏当前是否可见:方向键只续期计时器,不改变可见性。 */
const uiVisibleRef = useRef(uiVisible)
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
const pendingSeekRef = useRef<number | null>(null)
@@ -318,6 +320,10 @@ export function PlayerControls({
uiLockedRef.current = uiLocked
}, [uiLocked])
useEffect(() => {
uiVisibleRef.current = uiVisible
}, [uiVisible])
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
const keepControlsVisible =
@@ -682,10 +688,12 @@ export function PlayerControls({
setSeekHover(clampRatio((event.clientX - rect.left) / rect.width))
}
const revealControls = useCallback(() => {
// 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。
if (uiLockedRef.current) return
onUiVisibleChange(true)
/**
* 方向键调进度时只续期自动隐藏计时器,不主动唤出操作栏:控制栏原本隐藏时
* 保持画面干净(只留轻量的快进/回退提示),原本可见时也不会在连续按键中途消失。
*/
const refreshControlsTimer = useCallback(() => {
if (uiLockedRef.current || !uiVisibleRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
const el = video()
if (!el || el.paused || holdControlsRef.current) {
@@ -728,7 +736,7 @@ export function PlayerControls({
dir: applied < 0 ? 'back' : 'forward',
seconds: Math.abs(Math.round(applied)),
})
revealControls()
refreshControlsTimer()
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
@@ -750,7 +758,7 @@ export function PlayerControls({
if (!burst) return
applySeekRef.current(burst.base + burst.delta)
}, SEEK_APPLY_MS)
}, [revealControls, streamOffset, video])
}, [refreshControlsTimer, streamOffset, video])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+3 -1
View File
@@ -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)
}
}}
+83
View File
@@ -88,6 +88,16 @@ type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message:
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
// 手动标记的提示里展示的时间点(mm:ss 或 h:mm:ss)。
function formatMarkTime(totalSec: number): string {
const safe = Math.max(0, Math.floor(totalSec))
const hours = Math.floor(safe / 3600)
const minutes = Math.floor((safe % 3600) / 60)
const seconds = safe % 60
const pad = (n: number) => String(n).padStart(2, '0')
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
@@ -2245,6 +2255,73 @@ export function PlayerPage() {
if (activeSkip) performSkip(activeSkip)
}, [activeSkip, performSkip])
// ── 手动标记片头/片尾 ──────────────────────────────────────────────────────
// 服务端把手动标记的优先级排在最前,并按季传播到还没标记的集,所以这里只需
// 把「当前播放位置」发过去,再用返回的合并结果替换本地片段。
const manualMarkBusyRef = useRef(false)
const submitManualMark = useCallback(
(kind: 'intro' | 'credits', clear: boolean) => {
const current = mediaRef.current
if (!current?.id || manualMarkBusyRef.current) return
// 位置必须是源时间轴上的绝对秒数:本地 HLS 的 t=0 对应 hlsStartSec。
const video = ref.current
const positionSec = playbackPositionSec(
modeRef.current,
hlsSourceRef.current,
hlsStartSecRef.current,
video?.currentTime || 0,
)
const positionMs = Math.max(0, Math.round(positionSec * 1000))
if (!clear && positionMs <= 0) {
toast.error('当前位置为 0,请播放到片头结束后再标记')
return
}
manualMarkBusyRef.current = true
const markLabel = kind === 'intro' ? '片头' : '片尾'
const markTime = formatMarkTime(positionSec)
playbackAPI
.markSegment(current.id, {
kind,
position_ms: clear ? undefined : positionMs,
clear,
})
.then((res) => {
setRawSkipSegments(res.segments ?? [])
setAutoSkipIntro(Boolean(res.auto_skip))
setActiveSkip(null)
activeSkipKeyRef.current = ''
setDismissedSkipKinds([])
setAutoSuppressedKinds([])
if (clear) {
toast.success(`已清除${markLabel}标记`)
} else {
toast.success(`已标记${markLabel}:${markTime}(同季其它集已同步)`)
}
})
.catch(() => toast.error('标记保存失败,请稍后重试'))
.finally(() => {
manualMarkBusyRef.current = false
})
},
[],
)
const handleMarkIntro = useCallback(() => submitManualMark('intro', false), [submitManualMark])
const handleMarkOutro = useCallback(() => submitManualMark('credits', false), [submitManualMark])
const handleClearIntroMark = useCallback(() => submitManualMark('intro', true), [submitManualMark])
const handleClearOutroMark = useCallback(() => submitManualMark('credits', true), [submitManualMark])
// 已有手动标记的类型:用于按钮提示文案。只有手动标记可以清除,外部源的不算。
const markPresence = useMemo(() => {
let intro = false
let outro = false
for (const segment of rawSkipSegments) {
if (segment.kind === 'intro') intro = true
if (segment.kind === 'credits') outro = true
}
return { intro, outro }
}, [rawSkipSegments])
const handleUndoSkip = useCallback(() => {
const notice = skipNotice
setSkipNotice(null)
@@ -2547,6 +2624,12 @@ export function PlayerPage() {
onSkipPrompt={handleSkipClick}
skipNotice={skipNotice ? { text: skipNotice.text } : null}
onUndoSkip={handleUndoSkip}
onMarkIntro={handleMarkIntro}
onMarkOutro={handleMarkOutro}
onClearIntroMark={handleClearIntroMark}
onClearOutroMark={handleClearOutroMark}
hasIntroMark={markPresence.intro}
hasOutroMark={markPresence.outro}
playlistPanel={
isMobileTheater
? undefined
+126 -1
View File
@@ -242,6 +242,15 @@ type PlayerVideoStageProps = {
/** 自动跳过后的撤销提示;null 表示不显示。 */
skipNotice?: { text: string } | null
onUndoSkip?: () => void
/** 手动标记:把当前位置记为片头结束 / 片尾开始。 */
onMarkIntro?: () => void
onMarkOutro?: () => void
/** 长按清除已有的片头 / 片尾标记。 */
onClearIntroMark?: () => void
onClearOutroMark?: () => void
/** 当前是否已有对应类型的标记(决定按钮文案与长按是否可用)。 */
hasIntroMark?: boolean
hasOutroMark?: boolean
waiting?: boolean
waitingMessage?: string
}
@@ -312,6 +321,12 @@ export function PlayerVideoStage({
onSkipPrompt,
skipNotice = null,
onUndoSkip,
onMarkIntro,
onMarkOutro,
onClearIntroMark,
onClearOutroMark,
hasIntroMark = false,
hasOutroMark = false,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -335,6 +350,39 @@ export function PlayerVideoStage({
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lockVisible = lockHovered || lockShown
const vrActive = Boolean(vr360)
// 手动标记入口:画面左边缘一条半透明小白条,常态常驻(锁画面时才收),
// 点开才展开「片头 / 片尾」——展开的动作面板跟下面进度条同进同出。
// 悬停白条时按住操作栏:它不在操作栏自身的悬停判定范围内,鼠标停在上面时
// 操作栏会照常在三秒后收起,否则展开的面板会被一起带走。
const [markBarHovered, setMarkBarHovered] = useState(false)
const [markDockOpen, setMarkDockOpen] = useState(false)
const markDockRef = useRef<HTMLDivElement>(null)
const markDockVisible = !locked
const markPanelVisible = markDockVisible && controlsVisible
// 进度条收起(或画面锁定)就关掉标记面板:展开的动作跟操作栏同一套显隐规则。
useEffect(() => {
if (!markPanelVisible) setMarkDockOpen(false)
}, [markPanelVisible])
// 点画面别处、换片或按 Esc 都收起面板,避免它挂在那里挡住画面。
useEffect(() => {
if (!markDockOpen) return
const onDocPointerDown = (event: Event) => {
const dock = markDockRef.current
if (dock && event.target instanceof Node && dock.contains(event.target)) return
setMarkDockOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMarkDockOpen(false)
}
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
}, [markDockOpen])
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
@@ -552,6 +600,7 @@ export function PlayerVideoStage({
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
setControlsVisible(false)
setMarkBarHovered(false)
revealLock(!next)
}
@@ -561,6 +610,8 @@ export function PlayerVideoStage({
setLockShown(false)
setLockHint(false)
setLockHovered(false)
setMarkBarHovered(false)
setMarkDockOpen(false)
if (lockRevealTimerRef.current) {
clearTimeout(lockRevealTimerRef.current)
lockRevealTimerRef.current = null
@@ -892,7 +943,7 @@ export function PlayerVideoStage({
onPlaybackRateChange={onPlayerPlaybackRateChange}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
uiHold={vrUiHovered || markBarHovered}
uiLocked={locked}
subs={subs}
subtitleIndex={subtitleIndex}
@@ -966,6 +1017,80 @@ export function PlayerVideoStage({
) : null}
{danmakuPanel}
{playlistPanel}
{/* 手动标记片头/片尾:数据源查不到时用户自己标一次,服务端会按季传播。
常态只有画面左边缘一条半透明小白条;点开才展开「片头 / 片尾」,展开的
动作跟下面进度条同进同出。全屏时也一样,静止画面里只有这条小白条。
位置避开左侧居中的锁定按钮,不与它的悬停感应区重叠。 */}
{onMarkIntro || onMarkOutro ? (
<div
ref={markDockRef}
aria-hidden={!markDockVisible}
className={`absolute left-0 top-[78%] z-30 -translate-y-1/2 select-none transition duration-200 ${
markDockVisible ? 'opacity-100' : 'pointer-events-none -translate-x-1 opacity-0'
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onPointerEnter={(event) => {
// 只有真鼠标才按住操作栏:触摸端抬起后拿不到可靠的离开信号。
if (event.pointerType === 'mouse') setMarkBarHovered(true)
}}
onPointerLeave={() => setMarkBarHovered(false)}
>
<div className="flex items-center">
<button
type="button"
tabIndex={markDockVisible ? 0 : -1}
aria-expanded={markDockOpen}
aria-label={markDockOpen ? '收起片头片尾标记' : '标记片头 / 片尾'}
title={markDockOpen ? '收起' : '标记片头 / 片尾'}
onClick={() => {
// 展开的动作面板跟进度条走:操作栏收着时先把它亮出来。
if (!markDockOpen) setControlsVisible(true)
setMarkDockOpen((open) => !open)
}}
className="group flex h-12 items-center bg-transparent pl-0 pr-3 focus-visible:outline-none"
>
<span
className={`block h-10 rounded-r-full bg-white/40 shadow-[0_0_8px_rgba(0,0,0,0.55)] transition-all duration-200 group-hover:w-1.5 group-hover:bg-white/70 group-focus-visible:w-1.5 group-focus-visible:bg-white/90 ${
markDockOpen ? 'w-1.5 bg-white/70' : 'w-1'
}`}
/>
</button>
{markDockOpen && markPanelVisible ? (
<div className="flex items-center gap-0.5 rounded-full border border-white/10 bg-black/70 py-0.5 pl-2 pr-0.5 text-[11px] text-white shadow-[0_6px_24px_rgba(0,0,0,0.45)] backdrop-blur-md">
<span className="pr-0.5 text-[10px] tracking-wide text-white/40">标记</span>
<button
type="button"
onClick={() => onMarkIntro?.()}
onContextMenu={(event) => {
if (!hasIntroMark) return
event.preventDefault()
onClearIntroMark?.()
}}
title={hasIntroMark ? '点击重新标记片头,右键清除' : '把当前位置标为片头结束'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片头
</button>
<button
type="button"
onClick={() => onMarkOutro?.()}
onContextMenu={(event) => {
if (!hasOutroMark) return
event.preventDefault()
onClearOutroMark?.()
}}
title={hasOutroMark ? '点击重新标记片尾,右键清除' : '把当前位置标为片尾开始'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片尾
</button>
</div>
) : null}
</div>
</div>
) : null}
</>
) : loadError ? (
<p className="max-w-[92%] text-center text-sm text-rose-400">{loadError}</p>
+7
View File
@@ -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)
}
+15 -10
View File
@@ -23,11 +23,24 @@ export const generalSettingsGroup: SettingGroup = {
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
defaultValue: 'false',
},
{
key: 'segment.source',
label: '片头/片尾数据源',
type: 'select',
hint: '选择查询哪个社区库来补全片头/片尾时间点。TheIntroDB 数据最全(按 TMDb id);IntroDB 只认 IMDb id,需要 TMDb 已配置 API key 才能解析;TODb 偏华语片源、按平台版本记录。选「关闭」后只使用本地已有数据(含手动标记),不再请求外网。',
defaultValue: 'theintrodb',
options: [
{ value: 'theintrodb', label: 'TheIntroDB' },
{ value: 'introdb', label: 'IntroDB' },
{ value: 'todb', label: 'TODb(TheOtherDB)' },
{ value: 'off', label: '关闭' },
],
},
{
key: 'segment.prewarm_enabled',
label: '后台预热 IntroDB 片头片段',
label: '后台预热片头片段',
type: 'toggle',
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
hint: '默认关闭。开启后每 6 小时在后台向上面选择的数据源拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
defaultValue: 'false',
},
{
@@ -130,14 +143,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',
+6
View File
@@ -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
+12 -1
View File
@@ -15,7 +15,7 @@ function equal<T>(name: string, actual: T, expected: T) {
check('空片段返回空数组', toSkipSegments(null, 600).length === 0)
check('空数组返回空数组', toSkipSegments([], 600).length === 0)
// end_ms: 0 表示延续到片尾,用媒体时长补齐。
// end_ms: 0 只在片尾类(credits/preview)表示「延续到片尾」,用媒体时长补齐。
const resolved = toSkipSegments(
[
{ kind: 'intro', start_ms: 228_664, end_ms: 246_143 },
@@ -28,6 +28,17 @@ equal('片头起点(秒)', resolved[0].startSec, 228.664)
equal('片头终点(秒)', resolved[0].endSec, 246.143)
equal('片尾终点补齐为总时长', resolved[1].endSec, 3600)
// 片头 / 回顾的 end_ms: 0 是数据源的「这段不存在」占位(TheIntroDB 官方语义),
// 绝不能补齐成总时长——那会让整集都落在片头区间里,一开播就弹跳过按钮。
check(
'片头 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'intro', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
check(
'回顾 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'recap', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
// 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。
check(
'时长未知时丢弃开放区间',
+15 -5
View File
@@ -33,8 +33,11 @@ export function skippedNoticeText(kind: PlaybackSegmentKind): string {
/**
* 把服务端返回的片段换算成源时间轴上的秒区间。
*
* end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值)
* 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
* end_ms === 0 是「一直到片尾」的哨兵值,只有片尾类(credits / preview)会这么
* 返回,用媒体总时长补齐。片头 / 回顾出现 0 一律丢弃:那通常意味着数据源根本没
* 给出结束点(TheIntroDB 用 end_ms: 0 表示「这段不存在」),补成总时长会让整集
* 都落在「片头」区间里,一开播就弹跳过按钮。时长未知(0 / 非有限值)同样丢弃,
* 否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
*/
export function toSkipSegments(
segments: PlaybackSegment[] | null | undefined,
@@ -44,9 +47,16 @@ export function toSkipSegments(
const out: SkipSegment[] = []
for (const segment of segments) {
const startSec = Math.max(0, segment.start_ms / 1000)
const endSec = segment.end_ms > 0 ? segment.end_ms / 1000 : durationSec
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
const openEnded = segment.kind === 'credits' || segment.kind === 'preview'
if (segment.end_ms > 0) {
const endSec = segment.end_ms / 1000
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
continue
}
if (!openEnded) continue
if (!Number.isFinite(durationSec) || durationSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec: durationSec })
}
return out.sort((a, b) => a.startSec - b.startSec)
}