mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fe75e136 | |||
| e65accf2cd | |||
| f54ef2228c | |||
| 3c2bfb14ca | |||
| 8f64f961ac | |||
| 6797394c5b | |||
| e71d9d61d1 | |||
| 1ff60d5641 | |||
| 3f361631d0 |
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
|
||||
fontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(fontResp, fontReq)
|
||||
if fontResp.Code != http.StatusOK {
|
||||
t.Fatalf("font status = %d, want 200", fontResp.Code)
|
||||
}
|
||||
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
|
||||
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
|
||||
}
|
||||
if got := fontResp.Body.String(); got != "wOF2-test-font" {
|
||||
t.Fatalf("font body = %q, want wOF2-test-font", got)
|
||||
}
|
||||
|
||||
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||
missingFontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingFontResp, missingFontReq)
|
||||
if missingFontResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
|
||||
}
|
||||
if strings.Contains(missingFontResp.Body.String(), "index") {
|
||||
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
|
||||
}
|
||||
|
||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
||||
brandResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(brandResp, brandReq)
|
||||
|
||||
+12
-5
@@ -59,6 +59,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
c.Next()
|
||||
})
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
fonts := r.Group("/fonts")
|
||||
fonts.Use(middleware.GzipStatic())
|
||||
fonts.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Next()
|
||||
})
|
||||
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
@@ -70,11 +77,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -88,7 +89,6 @@ require (
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -48,6 +48,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
|
||||
@@ -124,6 +124,11 @@ type CacheConfig struct {
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -14,7 +15,13 @@ import (
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
uid := currentUserID(c)
|
||||
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
|
||||
opts := service.DanmakuFetchOptions{
|
||||
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
|
||||
}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -28,6 +35,34 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
|
||||
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if req.MergeSources == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
|
||||
return
|
||||
}
|
||||
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func newDanmakuSettingsService(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "user-1"
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.MergeSources {
|
||||
t.Fatal("response should echo merge_sources=true")
|
||||
}
|
||||
// 落库校验:重新读取应为 true。
|
||||
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||
t.Fatal("merge preference was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c2)
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.MergeSources {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
router := newEmbyCompatTestRouter(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||
// 的 HLS 兜底路由接走返回空 404。
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("expected an empty array, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||
t.Fatalf("content type = %q, want application/json", ct)
|
||||
}
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||
}
|
||||
}
|
||||
@@ -29,27 +29,90 @@ var embyPlaceholderPNG = []byte{
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
embyServeImage(c, svc, c.Param("id"), c.Param("type"), false)
|
||||
}
|
||||
}
|
||||
|
||||
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
embyServeImage(c, svc, c.Param("name"), c.Param("type"), true)
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
if svc == nil || svc.Emby == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
var raw string
|
||||
var err error
|
||||
if person {
|
||||
raw, err = svc.Emby.PersonImageURL(ctx, id, imageType)
|
||||
} else {
|
||||
raw, err = svc.Emby.ImageURL(ctx, id, imageType)
|
||||
}
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
|
||||
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if svc == nil || svc.Emby == nil || id == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||
if infos == nil {
|
||||
infos = []map[string]any{}
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
}
|
||||
|
||||
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := strings.TrimSpace(c.Param("userId"))
|
||||
raw := ""
|
||||
if svc != nil && svc.Emby != nil && uid != "" {
|
||||
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||
}
|
||||
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||
embyMissingAvatar(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||
embyMissingAvatar(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||
func embyMissingAvatar(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
|
||||
@@ -115,12 +115,31 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
//
|
||||
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||
// 返回 text/plain 的 404。
|
||||
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
@@ -202,6 +221,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
|
||||
@@ -563,6 +563,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return remoteItems
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
|
||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||
TMDbID: 503314,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||
}
|
||||
seenSeries := false
|
||||
seenMovie := false
|
||||
for _, item := range res.Items {
|
||||
switch item.TMDbID {
|
||||
case 61709:
|
||||
seenSeries = true
|
||||
if item.EpisodeNum != 1 {
|
||||
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||
}
|
||||
case 503314:
|
||||
seenMovie = true
|
||||
}
|
||||
}
|
||||
if !seenSeries || !seenMovie {
|
||||
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
|
||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
|
||||
@@ -31,6 +31,10 @@ type User struct {
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
|
||||
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
|
||||
// 用户的开关影响其他人。
|
||||
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDanmakuEpisodeNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "1"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "18"},
|
||||
{"第11话", "11"},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "29"},
|
||||
{"第 202 集", "202"},
|
||||
{"第1.5话 特别篇", "1.5"},
|
||||
{"正片", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeNumber(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeNumber(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDanmakuEpisodeSubtitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "裏切りの大空"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "自己相似的两性同体-Fractal Androgynous-"},
|
||||
{"第11话", ""},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "那我们走吧"},
|
||||
{"正片", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeSubtitle(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeSubtitle(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作
|
||||
// 或缺少集标题的平台源抢走。
|
||||
func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"},
|
||||
}},
|
||||
{AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("matched %d anime, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 {
|
||||
t.Fatalf("picked wrong source: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
|
||||
// 且顺序不可靠。必须靠副标题选中正确的那一集。
|
||||
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
|
||||
// 实测样本:官方 match「命运石之门 第18话 自己相似的两性同体-…」,
|
||||
// 配置源首条却是《命运石之门 0》的同一集号。
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11072, EpisodeTitle: "【dandan&animeko】 第18话 并进对称的牵牛星-Translational"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11095, EpisodeTitle: "【dandan&animeko】 第18话 自己相似的两性同体-Fractal Androgynous-"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "18", "第18话 自己相似的两性同体-Fractal Androgynous-")
|
||||
if !ok {
|
||||
t.Fatal("expected a confident match")
|
||||
}
|
||||
if got != 11095 {
|
||||
t.Fatalf("picked episodeId %d, want 11095 (first candidate is a different season)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标带副标题但没有任何候选的副标题对得上时,必须放弃而不是退回第一条,
|
||||
// 否则会给用户播放另一部番的弹幕。
|
||||
func TestPickDanmakuEpisodeIDRejectsWhenSubtitleNotFound(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番 第一季", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 111, EpisodeTitle: "第3话 完全不同的标题"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "3", "第3话 期望的标题"); ok {
|
||||
t.Fatalf("expected no match, got episodeId %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标没有副标题(如「第11话」)时,退而要求集数一致;集数对不上同样放弃。
|
||||
func TestPickDanmakuEpisodeIDNumberOnlyFallback(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "86 第二季", Episodes: []DanmakuEpisode{
|
||||
// 该源对第二季采用绝对集号,第 11 集记作第 22 话。
|
||||
{EpisodeID: 11113, EpisodeTitle: "第22话 辛"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "11", "第11话"); ok {
|
||||
t.Fatalf("episode number mismatch must be rejected, got episodeId %d", got)
|
||||
}
|
||||
|
||||
same := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 222, EpisodeTitle: "第11话"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(same, "11", "第11话")
|
||||
if !ok || got != 222 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (222,true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDanmakuEpisodeIDSkipsInvalidIDs(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 0, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: -5, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: 333, EpisodeTitle: "第1话 目标"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "1", "第1话 目标")
|
||||
if !ok || got != 333 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (333,true)", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -105,27 +105,359 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
|
||||
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
|
||||
}
|
||||
|
||||
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
|
||||
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
|
||||
// 第 1 层拉弹幕:官方 match 给出的 episodeId 属于官方 ID 空间,不能直接拿去
|
||||
// 请求第三方源(真实源上只会 404)。必须用官方给到的剧名+集数在配置源里重定位
|
||||
// 到配置源自己的 episodeId,再用它拉弹幕。
|
||||
func TestDanmakuFetchHashMatchRemapsEpisodeIDToConfiguredSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
|
||||
nil)
|
||||
// 配置源使用自己的 ID 空间:官方是 90001,配置源是 25484。
|
||||
// 副标题一致,用于跨源确认是同一集。
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕A</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mC", "", "")
|
||||
require.NoError(t, err)
|
||||
// 配置源优先:弹幕来自自定义源而非官方。
|
||||
// 弹幕取自配置源,且用的是重定位后的 ID。
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.NotContains(t, res.Raw, "弹幕B官方")
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
}
|
||||
|
||||
// 配置源能定位到该集,但返回的是空弹幕库(count=0)时必须回官方兜底:
|
||||
// 第三方目录里有条目不代表真的收录了弹幕。
|
||||
func TestDanmakuFetchHashMatchFallsBackWhenConfiguredLibraryIsEmpty(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// 该集在配置源上存在,但弹幕为空。
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":0,"comments":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方兜底弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mEmpty", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mEmpty", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "官方兜底弹幕")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
// 同一集在配置源里命中多个来源时:自动加载第一条,其余作为可切换来源返回,
|
||||
// 让用户能在面板里直接切换,而不必重新搜索。
|
||||
func TestDanmakuFetchHashMatchReturnsAlternatives(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "多来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一个副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个来源都指向同一集(副标题与集数一致),模拟 LogVar 聚合多站。
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"多来源动画 from dandan","episodes":[{"episodeId":101,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"多来源动画 from bilibili","episodes":[{"episodeId":102,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":3,"animeTitle":"多来源动画 from qq","episodes":[{"episodeId":103,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/101", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">首选来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":9,"animeTitle":"多来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mAlt", "多来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mAlt", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 自动加载第一个来源(沿用既有取值逻辑)。
|
||||
require.Contains(t, res.Raw, "首选来源弹幕")
|
||||
require.Equal(t, int64(101), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
|
||||
// 三个来源全部作为可切换列表返回。
|
||||
require.Len(t, res.Alternatives, 3)
|
||||
var ids []int64
|
||||
for _, a := range res.Alternatives {
|
||||
for _, e := range a.Episodes {
|
||||
ids = append(ids, e.EpisodeID)
|
||||
}
|
||||
}
|
||||
require.Equal(t, []int64{101, 102, 103}, ids)
|
||||
|
||||
// Candidates 的语义必须保持不变(这里不是"必须选择"),否则前端会停止自动加载。
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// 只有一个来源时不应产生 alternatives,避免面板出现无意义的单条列表。
|
||||
func TestDanmakuFetchHashMatchNoAlternativesForSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "单来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "唯一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1,"animeTitle":"单来源动画","episodes":[{"episodeId":201,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/201", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">唯一来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90002,"animeId":9,"animeTitle":"单来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mOne", "单来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mOne", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "唯一来源弹幕")
|
||||
require.Empty(t, res.Alternatives)
|
||||
}
|
||||
|
||||
// 开启合并后:同一集的多个来源被合并,重复弹幕(时间+内容相同)只保留一条。
|
||||
func TestDanmakuFetchMergeSourcesCombinesAndDeduplicates(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":301,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":302,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// A 与 B 各有一条重复弹幕(1.0 秒「重复弹幕」)和各自独有的一条。
|
||||
cfgMux.HandleFunc("/api/v2/comment/301", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u1","m":"重复弹幕"},`+
|
||||
`{"p":"2.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/302", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u2","m":"重复弹幕"},`+
|
||||
`{"p":"3.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90003,"animeId":9,"animeTitle":"合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mMerge", "合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.FetchWithOptions(ctx, "mMerge", "", "", DanmakuFetchOptions{MergeSources: true})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, res.MergedSources, "expected both sources to be merged")
|
||||
|
||||
merged := parseDanmakuComments(res.Raw)
|
||||
require.Len(t, merged, 3, "duplicate comment must collapse: got %#v", merged)
|
||||
require.Equal(t, "重复弹幕", merged[0].Text)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "只在A", merged[1].Text)
|
||||
require.Equal(t, "只在B", merged[2].Text)
|
||||
// 合并结果用 JSON 输出,前端据此选择解析分支。
|
||||
require.Equal(t, "json", res.SourceType)
|
||||
}
|
||||
|
||||
// 未开启合并时,行为与原来一致:只加载自动选中的那一个来源。
|
||||
func TestDanmakuFetchWithoutMergeLoadsSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "不合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"来源A","episodes":[{"episodeId":401,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":402,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/401", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/402", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90004,"animeId":9,"animeTitle":"不合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMerge", "不合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMerge", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, res.MergedSources)
|
||||
require.Equal(t, int64(401), res.EpisodeID)
|
||||
// 只应有自动选中来源的弹幕。
|
||||
require.Contains(t, res.Raw, "只在A")
|
||||
require.NotContains(t, res.Raw, "只在B")
|
||||
// 两个来源仍然作为可切换项返回(合并开关不影响候选列表)。
|
||||
require.Len(t, res.Alternatives, 2)
|
||||
}
|
||||
|
||||
// 合并偏好按用户持久化。
|
||||
func TestDanmakuMergeSourcesPreferencePersistsPerUser(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA := model.User{Username: "merge-user-a", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userA.ID = "user-a"
|
||||
userB := model.User{Username: "merge-user-b", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userB.ID = "user-b"
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userA))
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userB))
|
||||
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
require.NoError(t, svc.SetMergeSources(ctx, "user-a", true))
|
||||
require.True(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
// 另一个用户不受影响。
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-b"))
|
||||
|
||||
// 重新读取确认已落库,且 ConfigForUser 会带出该偏好。
|
||||
cfg := svc.ConfigForUser(ctx, "user-a")
|
||||
require.True(t, cfg.MergeSources)
|
||||
require.False(t, svc.ConfigForUser(ctx, "user-b").MergeSources)
|
||||
}
|
||||
|
||||
// 配置源搜不到对应剧集时必须回退官方:用官方自身的 episodeId 请求官方,
|
||||
// 而不是拿官方 ID 去撞配置源。
|
||||
func TestDanmakuFetchHashMatchFallsBackToOfficialWhenConfiguredHasNoMatch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "冷门动画.第01话.mkv")
|
||||
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"冷门动画","episodeTitle":"第1话 无人知晓"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕来自官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMatch", "冷门动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMatch", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕来自官方")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
@@ -159,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕官方兜底")
|
||||
}
|
||||
|
||||
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
|
||||
// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配;
|
||||
// 应继续走第 2 层按文件名+集数搜索。
|
||||
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[]}`,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
@@ -178,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
// 第 2 层命中:搜索请求按文件名进行。
|
||||
require.Contains(t, cfgSrv.lastSearch, "anime=")
|
||||
require.Equal(t, "filename", res.MatchMode)
|
||||
require.Equal(t, "测试动画", res.AnimeTitle)
|
||||
// 第 2 层命中:搜索请求按文件名进行,而不是误用官方第一条模糊候选。
|
||||
query, err := url.ParseQuery(cfgSrv.lastSearch)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, query.Get("anime"), "测试动画.第01话")
|
||||
require.NotContains(t, query.Get("anime"), "91天")
|
||||
}
|
||||
|
||||
// hash 未精确命中时,若本地已有刮削的剧名、年份、集数和集标题,应先用这些
|
||||
// 元数据锁定正确来源,而不是直接进入大量候选的手工选择。
|
||||
func TestDanmakuFetchHashMissUsesScrapedMetadata(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "local-release-name.mkv")
|
||||
cfgSrv := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"命运石之门 0(2018)【TV动画】from dandan&animeko","episodes":[{"episodeId":120140001,"episodeTitle":"【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}]},`+
|
||||
`{"animeId":2,"animeTitle":"命运石之门(2011)【TV动画】from dandan&animeko","episodes":[{"episodeId":25484,"episodeTitle":"【dandan&animeko】 第1话 始与终的序章-Turning Point-"}]},`+
|
||||
`{"animeId":3,"animeTitle":"命运石之门(2011)【动漫】from 360","episodes":[{"episodeId":120140002,"episodeTitle":"【qq】 第1集"}]}`+
|
||||
`]}`)
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
media := model.Media{
|
||||
Title: "命运石之门",
|
||||
Year: 2011,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Path: videoPath,
|
||||
SizeBytes: 32000,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
media.ID = "mScrapedMeta"
|
||||
require.NoError(t, svc.repo.DB.Create(&media).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, media.ID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, "metadata", res.MatchMode)
|
||||
require.Equal(t, "命运石之门(2011)【TV动画】from dandan&animeko", res.AnimeTitle)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||
@@ -446,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
|
||||
// 即使刮削元数据完整,也应先走准确率最高的 hash 层。
|
||||
func TestDanmakuFetchPrefersHashOverCompleteMetadata(t *testing.T) {
|
||||
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
var seen string
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"官方测试动画","episodeTitle":"第1话"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">hash命中</d></i>`,
|
||||
&seen)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
source := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL()))
|
||||
|
||||
m := model.Media{
|
||||
Title: "测试动画",
|
||||
Path: videoPath,
|
||||
SizeBytes: 123,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Year: 2011,
|
||||
}
|
||||
m.ID = "hash-first"
|
||||
require.NoError(t, svc.repo.DB.Create(&m).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, "hash-first", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
require.NotEmpty(t, res.Raw)
|
||||
require.Contains(t, seen, wantHash)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 弹幕合并:同一集在聚合源(如 LogVar)里常有多个库,开启合并后把这些库的
|
||||
// 弹幕一起去重后展示,而不是只显示其中一个。
|
||||
const (
|
||||
// danmakuMergeMaxSources 限制一次合并涉及的来源数量,避免把一次播放
|
||||
// 变成几十个上游请求。
|
||||
danmakuMergeMaxSources = 10
|
||||
// danmakuMergeConcurrency 限制并发抓取数,降低触发上游限流(429)的概率。
|
||||
danmakuMergeConcurrency = 4
|
||||
// danmakuMergeTimeToleranceSec 是判定「同一时间点」的容差。同一条弹幕在
|
||||
// 不同源之间可能因精度处理差上零点几秒,用容差比对;文本仍要求完全一致,
|
||||
// 因此不会把内容不同的弹幕误合。
|
||||
danmakuMergeTimeToleranceSec = 0.5
|
||||
)
|
||||
|
||||
// danmakuComment 是合并用的归一化弹幕。
|
||||
type danmakuComment struct {
|
||||
TimeSec float64
|
||||
Mode int
|
||||
Color int
|
||||
Text string
|
||||
}
|
||||
|
||||
// parseDanmakuComments 把上游载荷解析成归一化弹幕列表,兼容 dandanplay
|
||||
// JSON({comments:[{p,m}]})与 Bilibili XML(<d p="...">text</d>)两种格式。
|
||||
// 无法识别的载荷返回空列表,调用方据此跳过该来源。
|
||||
func parseDanmakuComments(raw string) []danmakuComment {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
if comments := parseDanmakuCommentsJSON(trimmed); len(comments) > 0 {
|
||||
return comments
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return parseDanmakuCommentsXML(trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsJSON(raw string) []danmakuComment {
|
||||
// 兼容 {comments:[...]} 与裸数组两种形态。
|
||||
var payload struct {
|
||||
Comments []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
// 少数自建源直接给结构化字段。
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
var bare []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
}
|
||||
if err2 := json.Unmarshal([]byte(raw), &bare); err2 != nil {
|
||||
return nil
|
||||
}
|
||||
payload.Comments = bare
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(payload.Comments))
|
||||
for _, item := range payload.Comments {
|
||||
comment, ok := buildDanmakuComment(item.P, item.M, item.Time, item.Text, item.Mode, item.Color)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsXML(raw string) []danmakuComment {
|
||||
var doc struct {
|
||||
Items []struct {
|
||||
P string `xml:"p,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
} `xml:"d"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(doc.Items))
|
||||
for _, item := range doc.Items {
|
||||
comment, ok := buildDanmakuComment(item.P, item.Text, nil, "", nil, nil)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDanmakuComment 从 p 串或结构化字段构造一条弹幕。p 串格式为
|
||||
// "time,mode,color,user"(dandanplay 四段式)。
|
||||
func buildDanmakuComment(p, text string, timeSec *float64, plainText string, mode, color *int) (danmakuComment, bool) {
|
||||
body := strings.TrimSpace(text)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(plainText)
|
||||
}
|
||||
if body == "" {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
comment := danmakuComment{Text: body, Mode: 1}
|
||||
if timeSec != nil {
|
||||
comment.TimeSec = *timeSec
|
||||
}
|
||||
if mode != nil && *mode > 0 {
|
||||
comment.Mode = *mode
|
||||
}
|
||||
if color != nil {
|
||||
comment.Color = *color
|
||||
}
|
||||
if fields := strings.Split(p, ","); len(fields) >= 1 {
|
||||
if t, err := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64); err == nil {
|
||||
comment.TimeSec = t
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
if m, err := strconv.Atoi(strings.TrimSpace(fields[1])); err == nil && m > 0 {
|
||||
comment.Mode = m
|
||||
}
|
||||
}
|
||||
// 颜色所在位置取决于格式,用段数区分(与前端 parseBilibiliXml 的判定
|
||||
// 一致):Bilibili 的 p 是 "time,mode,fontSize,color,..."(>=5 段,
|
||||
// 颜色在第 4 段);dandanplay 的 p 是 "time,mode,color,userId"(4 段,
|
||||
// 颜色在第 3 段)。不区分会把字号当成颜色。
|
||||
colorIndex := 2
|
||||
if len(fields) >= 5 {
|
||||
colorIndex = 3
|
||||
}
|
||||
if len(fields) > colorIndex {
|
||||
if c, err := strconv.Atoi(strings.TrimSpace(fields[colorIndex])); err == nil {
|
||||
comment.Color = c
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsNaN(comment.TimeSec) || math.IsInf(comment.TimeSec, 0) || comment.TimeSec < 0 {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
// 无颜色信息时用白色,与前端默认一致。
|
||||
if comment.Color <= 0 {
|
||||
comment.Color = 16777215
|
||||
}
|
||||
return comment, true
|
||||
}
|
||||
|
||||
// mergeDanmakuComments 合并多组弹幕并按「时间 + 内容」去重。
|
||||
//
|
||||
// 判定重复的条件:文本完全一致,且时间差在 danmakuMergeTimeToleranceSec 以内。
|
||||
// 之所以同时要求文本一致,是因为容差本身不足以区分内容;之所以需要容差,
|
||||
// 是因为同一条弹幕在不同来源间可能因精度处理差上零点几秒。
|
||||
func mergeDanmakuComments(sets [][]danmakuComment) []danmakuComment {
|
||||
merged := make([]danmakuComment, 0, 512)
|
||||
// keptTimes[文本] = 已保留的该文本时间列表,用于就近比对。
|
||||
keptTimes := make(map[string][]float64)
|
||||
for _, set := range sets {
|
||||
for _, comment := range set {
|
||||
times := keptTimes[comment.Text]
|
||||
duplicate := false
|
||||
for _, kept := range times {
|
||||
if math.Abs(kept-comment.TimeSec) <= danmakuMergeTimeToleranceSec {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
continue
|
||||
}
|
||||
keptTimes[comment.Text] = append(times, comment.TimeSec)
|
||||
merged = append(merged, comment)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool { return merged[i].TimeSec < merged[j].TimeSec })
|
||||
return merged
|
||||
}
|
||||
|
||||
// encodeDanmakuComments 把合并结果编码成 dandanplay JSON,前端 parseDanmaku
|
||||
// 已支持该格式({comments:[{p,m}]})。
|
||||
func encodeDanmakuComments(comments []danmakuComment) string {
|
||||
type item struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
}
|
||||
payload := struct {
|
||||
Count int `json:"count"`
|
||||
Comments []item `json:"comments"`
|
||||
}{Count: len(comments), Comments: make([]item, 0, len(comments))}
|
||||
for i, comment := range comments {
|
||||
payload.Comments = append(payload.Comments, item{
|
||||
Cid: i + 1,
|
||||
P: fmt.Sprintf("%.2f,%d,%d,merged", comment.TimeSec, comment.Mode, comment.Color),
|
||||
M: comment.Text,
|
||||
T: int(comment.TimeSec),
|
||||
})
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDanmakuCommentsSupportsDandanplayJson(t *testing.T) {
|
||||
raw := `{"count":2,"comments":[` +
|
||||
`{"cid":1,"p":"1.50,1,16777215,userA","m":"第一条"},` +
|
||||
`{"cid":2,"p":"2.00,5,16711680,userB","m":"顶部红字"}]}`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 2)
|
||||
require.Equal(t, 1.5, comments[0].TimeSec)
|
||||
require.Equal(t, 1, comments[0].Mode)
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
require.Equal(t, "第一条", comments[0].Text)
|
||||
// 第 5 种模式是顶部弹幕,颜色 16711680 = 0xFF0000。
|
||||
require.Equal(t, 5, comments[1].Mode)
|
||||
require.Equal(t, 16711680, comments[1].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsSupportsBilibiliXml(t *testing.T) {
|
||||
raw := `<?xml version="1.0" encoding="UTF-8"?><i>` +
|
||||
`<d p="3.25,1,25,16777215,1700000000,0,abc,def">来自 XML</d>` +
|
||||
`</i>`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 1)
|
||||
require.Equal(t, 3.25, comments[0].TimeSec)
|
||||
require.Equal(t, "来自 XML", comments[0].Text)
|
||||
// Bilibili 的 5 段式里第 3 段是字号、第 4 段才是颜色,必须取第 4 段。
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsReturnsNilForUnsupportedPayload(t *testing.T) {
|
||||
require.Nil(t, parseDanmakuComments(""))
|
||||
require.Nil(t, parseDanmakuComments("not a danmaku payload"))
|
||||
}
|
||||
|
||||
// 同一时间 + 同一内容视为重复,只保留一条。
|
||||
func TestMergeDanmakuCommentsDeduplicatesByTimeAndText(t *testing.T) {
|
||||
setA := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"},
|
||||
{TimeSec: 5.0, Mode: 1, Color: 16777215, Text: "只有A有"},
|
||||
}
|
||||
setB := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"}, // 与 A 完全重复
|
||||
{TimeSec: 5.2, Mode: 1, Color: 16777215, Text: "只有B有"},
|
||||
}
|
||||
merged := mergeDanmakuComments([][]danmakuComment{setA, setB})
|
||||
require.Len(t, merged, 3)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "哈哈", merged[0].Text)
|
||||
require.Equal(t, "只有A有", merged[1].Text)
|
||||
require.Equal(t, "只有B有", merged[2].Text)
|
||||
}
|
||||
|
||||
// 时间差在容差内且文本一致时也判为重复(跨源可能有零点几秒的精度差)。
|
||||
func TestMergeDanmakuCommentsDeduplicatesWithinTolerance(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 10.0, Mode: 1, Text: "2333"}},
|
||||
{{TimeSec: 10.3, Mode: 1, Text: "2333"}},
|
||||
})
|
||||
require.Len(t, merged, 1)
|
||||
require.Equal(t, 10.0, merged[0].TimeSec)
|
||||
}
|
||||
|
||||
// 内容相同但时间相距较远时是两条独立弹幕,不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsSameTextAtDifferentTimes(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前方高能"}},
|
||||
{{TimeSec: 30.0, Mode: 1, Text: "前方高能"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
// 时间相同但内容不同也不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsDifferentTextAtSameTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "AAA"}},
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "BBB"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
func TestMergeDanmakuCommentsSortsByTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 9.0, Mode: 1, Text: "后"}},
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, 9.0, merged[1].TimeSec)
|
||||
}
|
||||
|
||||
// 编码结果必须能被前端的 dandanplay JSON 分支解析(p + m 两个字符串字段)。
|
||||
func TestEncodeDanmakuCommentsProducesDandanplayShape(t *testing.T) {
|
||||
encoded := encodeDanmakuComments([]danmakuComment{
|
||||
{TimeSec: 1.5, Mode: 5, Color: 16711680, Text: "顶部"},
|
||||
})
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Comments []struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(encoded), &payload))
|
||||
require.Equal(t, 1, payload.Count)
|
||||
require.Len(t, payload.Comments, 1)
|
||||
require.Equal(t, "1.50,5,16711680,merged", payload.Comments[0].P)
|
||||
require.Equal(t, "顶部", payload.Comments[0].M)
|
||||
|
||||
// 回环:编码后的载荷应能再次解析出一致的弹幕。
|
||||
roundTrip := parseDanmakuComments(encoded)
|
||||
require.Len(t, roundTrip, 1)
|
||||
require.Equal(t, 1.5, roundTrip[0].TimeSec)
|
||||
require.Equal(t, 5, roundTrip[0].Mode)
|
||||
require.Equal(t, 16711680, roundTrip[0].Color)
|
||||
require.Equal(t, "顶部", roundTrip[0].Text)
|
||||
}
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -57,6 +61,14 @@ type DanmakuRenderConfig struct {
|
||||
Opacity string `json:"opacity"`
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
|
||||
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
|
||||
type DanmakuFetchOptions struct {
|
||||
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
|
||||
MergeSources bool
|
||||
}
|
||||
|
||||
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
|
||||
@@ -71,13 +83,21 @@ type DanmakuRenderConfig struct {
|
||||
// metadata so the player UI can display which episode was loaded.
|
||||
type DanmakuFetchResult struct {
|
||||
DanmakuRenderConfig
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
// Alternatives 是「同一集的其它可选来源」。与 Candidates 语义不同:
|
||||
// Candidates 表示自动匹配不唯一、必须由用户选择后才能加载弹幕;
|
||||
// Alternatives 表示弹幕已经自动加载好了,这里额外提供同集的其它来源
|
||||
// (LogVar 聚合了多个视频网站,同一集常有多个库)供用户随时切换,
|
||||
// 不必再手动搜索一遍。
|
||||
Alternatives []DanmakuAnime `json:"alternatives,omitempty"`
|
||||
AnimeTitle string `json:"anime_title,omitempty"`
|
||||
EpisodeTitle string `json:"episode_title,omitempty"`
|
||||
EpisodeID int64 `json:"episode_id,omitempty"`
|
||||
MatchMode string `json:"match_mode,omitempty"`
|
||||
// MergedSources 表示本次结果由多少个来源合并而成(未合并时为 0)。
|
||||
MergedSources int `json:"merged_sources,omitempty"`
|
||||
}
|
||||
|
||||
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
|
||||
@@ -118,6 +138,12 @@ type DanmakuService struct {
|
||||
|
||||
hashCacheMu sync.Mutex
|
||||
hashCache map[string]string // stamp → 16MB-prefix MD5
|
||||
|
||||
// resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走
|
||||
// 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。
|
||||
resultCacheMu sync.Mutex
|
||||
resultCache map[string]danmakuResultCacheEntry
|
||||
fetchGroup singleflight.Group
|
||||
}
|
||||
|
||||
func danmakuHTTPClient() *http.Client {
|
||||
@@ -129,10 +155,11 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DanmakuService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: danmakuHTTPClient(),
|
||||
hashCache: make(map[string]string),
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: danmakuHTTPClient(),
|
||||
hashCache: make(map[string]string),
|
||||
resultCache: make(map[string]danmakuResultCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +179,121 @@ func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolv
|
||||
}
|
||||
}
|
||||
|
||||
// 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行
|
||||
// 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化
|
||||
// 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。
|
||||
const (
|
||||
danmakuResultCacheTTL = 6 * time.Hour
|
||||
danmakuResultCacheMaxEntries = 64
|
||||
// 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。
|
||||
danmakuResultCacheCandidateTTL = 10 * time.Minute
|
||||
// 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。
|
||||
danmakuResultCacheEmptyTTL = time.Minute
|
||||
)
|
||||
|
||||
type danmakuResultCacheEntry struct {
|
||||
result *DanmakuFetchResult
|
||||
expiresAt time.Time
|
||||
storedAt time.Time
|
||||
}
|
||||
|
||||
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(source),
|
||||
mediaID,
|
||||
strings.TrimSpace(keyword),
|
||||
strings.TrimSpace(episodeID),
|
||||
strconv.FormatBool(merge),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
|
||||
if s == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
s.resultCacheMu.Lock()
|
||||
defer s.resultCacheMu.Unlock()
|
||||
entry, ok := s.resultCache[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.resultCache, key)
|
||||
return nil, false
|
||||
}
|
||||
return cloneDanmakuFetchResult(entry.result), true
|
||||
}
|
||||
|
||||
func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) {
|
||||
if s == nil || key == "" || result == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
s.resultCacheMu.Lock()
|
||||
defer s.resultCacheMu.Unlock()
|
||||
if s.resultCache == nil {
|
||||
s.resultCache = make(map[string]danmakuResultCacheEntry)
|
||||
}
|
||||
ttl := danmakuResultCacheTTLFor(result)
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries {
|
||||
oldestKey := ""
|
||||
var oldest time.Time
|
||||
for k, entry := range s.resultCache {
|
||||
if oldestKey == "" || entry.storedAt.Before(oldest) {
|
||||
oldestKey, oldest = k, entry.storedAt
|
||||
}
|
||||
}
|
||||
delete(s.resultCache, oldestKey)
|
||||
}
|
||||
s.resultCache[key] = danmakuResultCacheEntry{
|
||||
result: cloneDanmakuFetchResult(result),
|
||||
expiresAt: now.Add(ttl),
|
||||
storedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得
|
||||
// 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。
|
||||
func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
if strings.TrimSpace(result.Raw) != "" {
|
||||
return danmakuResultCacheTTL
|
||||
}
|
||||
if len(result.Candidates) > 0 {
|
||||
return danmakuResultCacheCandidateTTL
|
||||
}
|
||||
return danmakuResultCacheEmptyTTL
|
||||
}
|
||||
|
||||
// cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。
|
||||
func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.Candidates = cloneDanmakuAnimeList(in.Candidates)
|
||||
out.Alternatives = cloneDanmakuAnimeList(in.Alternatives)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]DanmakuAnime, len(in))
|
||||
for i, anime := range in {
|
||||
out[i] = anime
|
||||
out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
@@ -177,6 +319,41 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
|
||||
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
|
||||
cfg := s.Config(ctx)
|
||||
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
|
||||
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return false
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.DanmakuMergeSources
|
||||
}
|
||||
|
||||
// SetMergeSources 持久化该用户的弹幕合并偏好。
|
||||
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return errors.New("danmaku settings unavailable")
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return errors.New("missing user")
|
||||
}
|
||||
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
|
||||
}
|
||||
|
||||
// Fetch retrieves danmaku for the given media. keyword overrides the
|
||||
// media-derived search term (empty = use the video's own name); pass it from
|
||||
// the player when the user searches for a custom title. episodeID forces a
|
||||
@@ -186,19 +363,60 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
// 1. match: MD5 of the first 16MB of the video (local file read directly,
|
||||
// .strm resolved to a direct link and range-fetched) → /api/v2/match
|
||||
// against the official endpoint, which yields the episode library id.
|
||||
// 2. search by the playing file's name + episode number.
|
||||
// 3. current auto-identification (original name → title → file name + episode,
|
||||
// 2. scraped metadata: title + episode number + episode title, filtered by
|
||||
// release year and subtitle to disambiguate same-name seasons.
|
||||
// 3. search by the playing file's name + episode number.
|
||||
// 4. current auto-identification (original name → title → file name + episode,
|
||||
// single hit used, several hits returned as candidates for the player).
|
||||
// 4. manual: the player picks from the returned candidates (episodeID /
|
||||
// 5. manual: the player picks from the returned candidates (episodeID /
|
||||
// keyword override).
|
||||
//
|
||||
// Comments are always fetched from the configured source first (when set)
|
||||
// and fall back to the official endpoint on failure; identification itself
|
||||
// and fall back to the official endpoint on failure. Only hash identification
|
||||
// always goes to the official endpoint.
|
||||
//
|
||||
// When danmaku is disabled the result carries Enabled=false so the player can
|
||||
// silently skip rendering.
|
||||
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
|
||||
return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{})
|
||||
}
|
||||
|
||||
// FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 +
|
||||
// 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。
|
||||
func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("danmaku service unavailable")
|
||||
}
|
||||
cfg := s.Config(ctx)
|
||||
if !cfg.Enabled {
|
||||
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
|
||||
}
|
||||
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
|
||||
// 等待期间可能已有同一 key 的请求写入缓存。
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
|
||||
if err != nil {
|
||||
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
|
||||
return res, err
|
||||
}
|
||||
s.resultCachePut(key, res)
|
||||
return res, nil
|
||||
})
|
||||
res, _ := value.(*DanmakuFetchResult)
|
||||
if err != nil {
|
||||
return cloneDanmakuFetchResult(res), err
|
||||
}
|
||||
return cloneDanmakuFetchResult(res), nil
|
||||
}
|
||||
|
||||
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
|
||||
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
|
||||
if !res.Enabled {
|
||||
return res, nil
|
||||
@@ -234,6 +452,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
}
|
||||
|
||||
target := ""
|
||||
// targetBase 是 target 所属的源。各源的 episodeId 空间互相独立,必须用
|
||||
// 产生该 ID 的源去请求弹幕,否则会拿到 404;默认沿用「配置源优先」行为。
|
||||
targetBase := configured
|
||||
// hashOfficialID 记录 hash 层的官方 episodeId。仅当配置源重定位成功时
|
||||
// 才填,用于「配置源该集无弹幕」时回官方兜底。
|
||||
hashOfficialID := int64(0)
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
|
||||
@@ -250,21 +474,59 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
target = fmt.Sprintf("%d", matches[0].EpisodeID)
|
||||
res.AnimeTitle = matches[0].AnimeTitle
|
||||
res.EpisodeTitle = matches[0].EpisodeTitle
|
||||
res.EpisodeID = matches[0].EpisodeID
|
||||
match := matches[0]
|
||||
res.AnimeTitle = match.AnimeTitle
|
||||
res.EpisodeTitle = match.EpisodeTitle
|
||||
res.EpisodeID = match.EpisodeID
|
||||
res.MatchMode = "hash"
|
||||
// match 返回的是官方 ID 空间的 episodeId,直接拿去问第三方源
|
||||
// 只会 404(实测各源 ID 空间独立)。先用官方给到的剧名+集数
|
||||
// 在配置源里重定位到它自己的 episodeId;定位不到就整条走官方。
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
if matched, ok := s.lookupConfiguredEpisodes(ctx, configured, match); ok {
|
||||
configuredID := firstDanmakuEpisodeID(matched)
|
||||
target = strconv.FormatInt(configuredID, 10)
|
||||
targetBase = configured
|
||||
res.EpisodeID = configuredID
|
||||
hashOfficialID = match.EpisodeID
|
||||
// 同集有多个来源时全部带上,供面板里直接切换。
|
||||
if len(matched) > 1 {
|
||||
res.Alternatives = matched
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
target = strconv.FormatInt(match.EpisodeID, 10)
|
||||
targetBase = official
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
// 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。
|
||||
// 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。
|
||||
if target == "" && !manualKeyword && media != nil {
|
||||
if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok {
|
||||
configuredID := firstDanmakuEpisodeID(matched)
|
||||
target = strconv.FormatInt(configuredID, 10)
|
||||
targetBase = base
|
||||
res.AnimeTitle = matched[0].AnimeTitle
|
||||
res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = configuredID
|
||||
res.MatchMode = "metadata"
|
||||
if len(matched) > 1 {
|
||||
res.Alternatives = matched
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil &&
|
||||
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
targetBase = base
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
@@ -273,10 +535,10 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
@@ -289,21 +551,159 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
targetBase = base
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "search"
|
||||
}
|
||||
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, targetBase, official, target)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
// 第三方目录里存在该集,不代表它真的收录了弹幕(实测部分条目返回
|
||||
// count=0)。这种「拿到空库」的情况要用官方 episodeId 再试一次,否则
|
||||
// 重定位后反而会静默变成无弹幕 —— 旧写法是靠官方 ID 撞 404 才走到官方
|
||||
// 兜底的,改用重定位就必须显式补上这一步。
|
||||
if hashOfficialID != 0 && danmakuCommentCount(raw) == 0 {
|
||||
officialTarget := strconv.FormatInt(hashOfficialID, 10)
|
||||
if officialRaw, officialType, officialErr := s.fetchCommentFromBase(ctx, official, officialTarget); officialErr == nil &&
|
||||
danmakuCommentCount(officialRaw) > 0 {
|
||||
raw, st = officialRaw, officialType
|
||||
res.EpisodeID = hashOfficialID
|
||||
} else if officialErr != nil {
|
||||
s.log.Debug("danmaku official fallback for empty configured library failed",
|
||||
zap.String("episode_id", officialTarget), zap.Error(officialErr))
|
||||
}
|
||||
}
|
||||
// 合并多来源:仅在上游确实返回了多个同集来源时才有意义。
|
||||
if opts.MergeSources && len(res.Alternatives) > 1 {
|
||||
if mergedRaw, mergedCount, ok := s.mergeAlternativeSources(ctx, targetBase, res.Alternatives, res.EpisodeID, raw); ok {
|
||||
raw, st = mergedRaw, "json"
|
||||
res.MergedSources = mergedCount
|
||||
}
|
||||
}
|
||||
res.Raw, res.SourceType = raw, st
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// mergeAlternativeSources 并发抓取同一集的多个来源,按「时间 + 内容」去重后
|
||||
// 合并成单个载荷。已有载荷(existingRaw)会被复用,避免重复请求。
|
||||
// 返回合并后的载荷、实际参与合并的来源数与是否成功。
|
||||
func (s *DanmakuService) mergeAlternativeSources(ctx context.Context, base string, alternatives []DanmakuAnime, existingID int64, existingRaw string) (string, int, bool) {
|
||||
ids := make([]int64, 0, len(alternatives)+1)
|
||||
seen := map[int64]bool{}
|
||||
if existingID > 0 {
|
||||
seen[existingID] = true
|
||||
ids = append(ids, existingID)
|
||||
}
|
||||
for _, anime := range alternatives {
|
||||
for _, ep := range anime.Episodes {
|
||||
if ep.EpisodeID <= 0 || seen[ep.EpisodeID] {
|
||||
continue
|
||||
}
|
||||
seen[ep.EpisodeID] = true
|
||||
ids = append(ids, ep.EpisodeID)
|
||||
if len(ids) >= danmakuMergeMaxSources {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(ids) >= danmakuMergeMaxSources {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 少于两个来源时无需合并。
|
||||
if len(ids) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
collected [][]danmakuComment
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, danmakuMergeConcurrency)
|
||||
)
|
||||
collect := func(raw string) {
|
||||
comments := parseDanmakuComments(raw)
|
||||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
collected = append(collected, comments)
|
||||
mu.Unlock()
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == existingID && existingRaw != "" {
|
||||
collect(existingRaw)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
body, _, err := s.fetchCommentFromBase(ctx, base, strconv.FormatInt(id, 10))
|
||||
if err != nil {
|
||||
// 单个来源失败不影响整体合并,静默跳过。
|
||||
return
|
||||
}
|
||||
collect(body)
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(collected) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
merged := mergeDanmakuComments(collected)
|
||||
if len(merged) == 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
encoded := encodeDanmakuComments(merged)
|
||||
if encoded == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return encoded, len(collected), true
|
||||
}
|
||||
|
||||
// fetchCommentFromBase 从单个源拉取弹幕,不做任何回退。
|
||||
func (s *DanmakuService) fetchCommentFromBase(ctx context.Context, base, target string) (raw, sourceType string, err error) {
|
||||
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
|
||||
if err != nil {
|
||||
return "", "auto", err
|
||||
}
|
||||
return raw, detectDanmakuSourceType(raw), nil
|
||||
}
|
||||
|
||||
// danmakuCommentCount 估算弹幕条数,用于判断某个源是否真的返回了内容。
|
||||
// 同时兼容 dandanplay JSON 与 Bilibili XML 两种载荷。
|
||||
func danmakuCommentCount(raw string) int {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "{"):
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Comments []json.RawMessage `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return 0
|
||||
}
|
||||
if payload.Count > 0 {
|
||||
return payload.Count
|
||||
}
|
||||
return len(payload.Comments)
|
||||
case strings.HasPrefix(trimmed, "<"):
|
||||
return strings.Count(trimmed, "<d ")
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// detectDanmakuSourceType guesses the comment payload format from its body.
|
||||
// The dandanplay protocol historically returned Bilibili-style XML, but newer
|
||||
// and self-hosted implementations return the dandanplay JSON shape
|
||||
@@ -724,7 +1124,10 @@ type danmakuMatch struct {
|
||||
|
||||
// matchOfficial identifies the video via POST /api/v2/match on the official
|
||||
// endpoint (always official, signed with the app credentials). Returns the
|
||||
// candidate list; empty means nothing matched.
|
||||
// candidate list only when the upstream explicitly reports a confident match;
|
||||
// empty means nothing matched. The API may return fuzzy suggestions together
|
||||
// with isMatched=false; those are not safe to auto-select because their first
|
||||
// item can belong to an unrelated anime.
|
||||
//
|
||||
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
|
||||
// names with errorCode 2 (verified against the live API — QueryEscape's
|
||||
@@ -774,13 +1177,14 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
|
||||
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Matches []danmakuMatch `json:"matches"`
|
||||
Success bool `json:"success"`
|
||||
IsMatched bool `json:"isMatched"`
|
||||
Matches []danmakuMatch `json:"matches"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
|
||||
}
|
||||
if !out.Success {
|
||||
if !out.Success || !out.IsMatched {
|
||||
return nil, nil
|
||||
}
|
||||
return out.Matches, nil
|
||||
@@ -794,13 +1198,14 @@ func sameDanmakuBase(a, b string) bool {
|
||||
return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
|
||||
}
|
||||
|
||||
// fetchCommentWithFallback fetches a comment library from the configured
|
||||
// source first (when set and different from official), falling back to the
|
||||
// official endpoint on failure.
|
||||
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
|
||||
// fetchCommentWithFallback fetches a comment library from primary first (when
|
||||
// set and different from official), falling back to the official endpoint on
|
||||
// failure. primary must be the source that issued target: episode ids are only
|
||||
// meaningful inside the source that produced them.
|
||||
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary, official, target string) (raw, sourceType string, err error) {
|
||||
var bases []string
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
bases = append(bases, configured)
|
||||
if primary != "" && !sameDanmakuBase(primary, official) {
|
||||
bases = append(bases, primary)
|
||||
}
|
||||
bases = append(bases, official)
|
||||
var lastErr error
|
||||
@@ -817,16 +1222,301 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configure
|
||||
return "", "auto", lastErr
|
||||
}
|
||||
|
||||
// searchCandidatesWithFallback searches the configured source first (when
|
||||
// set and different from official), falling back to the official endpoint on
|
||||
// failure.
|
||||
func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
|
||||
// searchCandidatesWithSource searches the configured source first (when set
|
||||
// and different from official), falling back to the official endpoint on
|
||||
// failure. It also reports which base produced the hits, because the caller
|
||||
// must fetch comments from that same base — episode ids are source-local.
|
||||
func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, string, error) {
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
candidates, err := s.searchCandidates(ctx, configured, name, episode)
|
||||
if err == nil {
|
||||
return candidates, nil
|
||||
return candidates, configured, nil
|
||||
}
|
||||
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
|
||||
}
|
||||
return s.searchCandidates(ctx, official, name, episode)
|
||||
candidates, err := s.searchCandidates(ctx, official, name, episode)
|
||||
return candidates, official, err
|
||||
}
|
||||
|
||||
// danmakuEpisodeNumberRE 从「第N话 / 第N話 / 第N集」里取出集数。各源标题格式
|
||||
// 不一(有的只有集数、有的带副标题),正则只认集数标记本身。
|
||||
var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`)
|
||||
|
||||
// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。
|
||||
var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`)
|
||||
|
||||
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
|
||||
func danmakuEpisodeNumber(title string) string {
|
||||
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// danmakuEpisodeSubtitle 返回「第N话」之后的副标题,用于区分同名前作/续作。
|
||||
func danmakuEpisodeSubtitle(title string) string {
|
||||
loc := danmakuEpisodeNumberRE.FindStringIndex(title)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(title[loc[1]:])
|
||||
}
|
||||
|
||||
// normalizeDanmakuText 去掉标点与空白,便于跨源比对副标题。注意它只做字符
|
||||
// 归一化,不剥离集数标记 —— 调用方传入的可能已经是剥离后的副标题。
|
||||
func normalizeDanmakuText(s string) []rune {
|
||||
out := make([]rune, 0, 32)
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。
|
||||
func danmakuTitleYear(title string) int {
|
||||
m := danmakuTitleYearRE.FindString(title)
|
||||
if len(m) != 6 {
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(m[1:5])
|
||||
return year
|
||||
}
|
||||
|
||||
// danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。
|
||||
//
|
||||
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
|
||||
// (「辛」「序曲」),但它们本身就是很强的标识。只有在做包含/前缀这类模糊
|
||||
// 比对时才要求足够长度,避免短串误配到别的集。同一集在不同源的副标题长度
|
||||
// 也可能不同(一侧带 "-Fractal Androgynous-" 之类后缀),故保留模糊分支。
|
||||
func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
|
||||
// candidateTitle 是完整标题,subtitle 已经剥离过集数标记,两者处理方式不同。
|
||||
a := normalizeDanmakuText(danmakuEpisodeSubtitle(candidateTitle))
|
||||
b := normalizeDanmakuText(subtitle)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
if string(a) == string(b) {
|
||||
return true
|
||||
}
|
||||
const fuzzyMinRunes = 4
|
||||
if len(a) < fuzzyMinRunes || len(b) < fuzzyMinRunes {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
|
||||
return true
|
||||
}
|
||||
if danmakuRuneSimilarity(a, b) >= 0.7 {
|
||||
return true
|
||||
}
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
if n > 12 {
|
||||
n = 12
|
||||
}
|
||||
return string(a[:n]) == string(b[:n])
|
||||
}
|
||||
|
||||
// danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。
|
||||
// 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离;
|
||||
// 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。
|
||||
func danmakuRuneSimilarity(a, b []rune) float64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
for _, ra := range a {
|
||||
cur := make([]int, len(b)+1)
|
||||
for j, rb := range b {
|
||||
if ra == rb {
|
||||
cur[j+1] = prev[j] + 1
|
||||
} else if cur[j] > prev[j+1] {
|
||||
cur[j+1] = cur[j]
|
||||
} else {
|
||||
cur[j+1] = prev[j+1]
|
||||
}
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
minLen := len(a)
|
||||
if len(b) < minLen {
|
||||
minLen = len(b)
|
||||
}
|
||||
return float64(prev[len(b)]) / float64(minLen)
|
||||
}
|
||||
|
||||
// lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新
|
||||
// 定位同一集,返回配置源里所有指向该集的结果。
|
||||
//
|
||||
// 必须重定位:各源 episodeId 空间互相独立,官方 ID(如 135500001)在第三方
|
||||
// 源上只是一个不存在的编号,直接请求必然 404。官方 match 返回的 animeTitle /
|
||||
// episodeTitle 正好提供了跨源检索所需的剧名、集数与副标题。
|
||||
//
|
||||
// 返回列表而非单条:LogVar 这类源聚合了多个视频网站,同一集常有多个库,
|
||||
// 调用方取第一条自动加载,其余作为可切换来源交给用户。
|
||||
func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configured string, m danmakuMatch) ([]DanmakuAnime, bool) {
|
||||
episodeNum := danmakuEpisodeNumber(m.EpisodeTitle)
|
||||
if episodeNum == "" || strings.TrimSpace(m.AnimeTitle) == "" {
|
||||
return nil, false
|
||||
}
|
||||
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
|
||||
if err != nil {
|
||||
s.log.Debug("danmaku configured lookup failed",
|
||||
zap.String("source", configured),
|
||||
zap.String("anime", m.AnimeTitle),
|
||||
zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
|
||||
if len(matched) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return matched, true
|
||||
}
|
||||
|
||||
// lookupScrapedEpisodes uses already-scraped media metadata to locate the
|
||||
// correct library when hash identification missed. Unlike the generic title
|
||||
// search, this path requires an explicit episode-title match and, when known,
|
||||
// a matching release year. That prevents Steins;Gate (2011) from being
|
||||
// confused with Steins;Gate 0 (2018) or other same-name entries.
|
||||
func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) {
|
||||
title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum)
|
||||
if err != nil {
|
||||
s.log.Debug("danmaku scraped metadata lookup failed",
|
||||
zap.String("title", title),
|
||||
zap.String("episode", episodeNum),
|
||||
zap.Error(err))
|
||||
return nil, "", false
|
||||
}
|
||||
matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle)
|
||||
if len(matched) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
return matched, base, true
|
||||
}
|
||||
|
||||
// scrapedDanmakuMetadata returns the query fields only when the media has
|
||||
// enough scraped information to identify a specific episode.
|
||||
func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) {
|
||||
if media == nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
title = strings.TrimSpace(media.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(media.OriginalName)
|
||||
}
|
||||
episodeTitle = strings.TrimSpace(media.EpisodeTitle)
|
||||
if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true
|
||||
}
|
||||
|
||||
// matchScrapedDanmakuEpisodes filters title search hits by release year and
|
||||
// episode subtitle. The returned list keeps the source grouping so multiple
|
||||
// libraries for the same episode remain switchable in the player.
|
||||
func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime {
|
||||
out := make([]DanmakuAnime, 0, len(candidates))
|
||||
for _, anime := range candidates {
|
||||
if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) {
|
||||
continue
|
||||
}
|
||||
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
anime.Episodes = hits
|
||||
out = append(out, anime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// danmakuAnimeTitleMatches checks the scraped title and, when present, the
|
||||
// candidate's year. A candidate with a conflicting explicit year is rejected
|
||||
// even if its title contains the scraped title.
|
||||
func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool {
|
||||
candidate := string(normalizeDanmakuText(animeTitle))
|
||||
target := string(normalizeDanmakuText(mediaTitle))
|
||||
if candidate == "" || target == "" || !strings.Contains(candidate, target) {
|
||||
return false
|
||||
}
|
||||
if year > 0 {
|
||||
if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。
|
||||
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
|
||||
for _, anime := range matched {
|
||||
for _, ep := range anime.Episodes {
|
||||
if ep.EpisodeID > 0 {
|
||||
return ep.EpisodeID
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pickDanmakuEpisodeID 从配置源的搜索结果里挑出与目标集最匹配的一条。
|
||||
//
|
||||
// 搜索按「剧名+集数」返回,但同名不同季/不同版本会同时命中,且顺序不保证
|
||||
// 正确:实测「命运石之门 第18话」首条是《命运石之门 0(2018)》、《战区88 OVA
|
||||
// 第1话》首条是《战区88(2004) TV》,两者内容都不对,只有副标题能区分。因此:
|
||||
// - 目标带副标题时,必须找到副标题一致的候选,否则放弃(宁可回退官方,
|
||||
// 也不能给用户放错番的弹幕);
|
||||
// - 目标没有副标题(如「第11话」)时,退而要求集数一致。
|
||||
func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle string) (int64, bool) {
|
||||
if id := firstDanmakuEpisodeID(matchDanmakuEpisodes(candidates, episodeNum, episodeTitle)); id != 0 {
|
||||
return id, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// matchDanmakuEpisodes 在搜索结果里筛出所有指向目标集的结果,保留原有的番剧
|
||||
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
|
||||
// 列表作为可切换来源展示。
|
||||
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
|
||||
out := make([]DanmakuAnime, 0, len(candidates))
|
||||
for _, anime := range candidates {
|
||||
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
anime.Episodes = hits
|
||||
out = append(out, anime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。
|
||||
func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode {
|
||||
subtitle := danmakuEpisodeSubtitle(episodeTitle)
|
||||
// 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」),
|
||||
// 不带「第1话」前缀。此时把它整体作为副标题参与比对。
|
||||
if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" {
|
||||
subtitle = strings.TrimSpace(episodeTitle)
|
||||
}
|
||||
hits := make([]DanmakuEpisode, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum {
|
||||
continue
|
||||
}
|
||||
// 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。
|
||||
if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, ep)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -22,7 +24,8 @@ func newDanmakuTestService(t *testing.T) *DanmakuService {
|
||||
// 独立临时文件库,避免测试间通过共享内存库串数据。
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
|
||||
// 需要 users 表:弹幕合并偏好按用户存储在 user 行上。
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}))
|
||||
repos := repository.New(db)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
@@ -325,3 +328,79 @@ func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
|
||||
require.Equal(t, "xml", res2.SourceType)
|
||||
require.Contains(t, res2.Raw, "弹幕A")
|
||||
}
|
||||
|
||||
// 同一集在缓存 TTL 内重复请求时不应再访问上游(搜索和评论都只发一次)。
|
||||
func TestDanmakuFetchCachesRepeatedRequests(t *testing.T) {
|
||||
var searchCalls, commentCalls int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&commentCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">缓存测试</d></i>`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "cache-media", "测试动画", "", 1)
|
||||
|
||||
first, err := svc.Fetch(ctx, "cache-media", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first.Raw)
|
||||
|
||||
second, err := svc.Fetch(ctx, "cache-media", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.Raw, second.Raw)
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&commentCalls))
|
||||
}
|
||||
|
||||
// 并发的同一集请求应被 singleflight 合并,上游只被访问一次。
|
||||
func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
|
||||
var searchCalls int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">并发测试</d></i>`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "concurrent-media", "测试动画", "", 1)
|
||||
|
||||
const workers = 8
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := svc.Fetch(ctx, "concurrent-media", "", "")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
|
||||
}
|
||||
|
||||
@@ -32,17 +32,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
}
|
||||
return backdrop
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
// Latest JSON can outlive (or be served after) the in-memory artwork
|
||||
// map. Rebuild from the library instead of handing the client a 1x1
|
||||
// placeholder that it then caches as a successful image.
|
||||
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, id)
|
||||
if err == nil && m != nil {
|
||||
@@ -71,6 +68,87 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
season, ok, err := e.findSeasonGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
||||
}
|
||||
series, ok, err := e.findSeriesGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
}
|
||||
|
||||
// PersonImageURL resolves a person avatar from either a disguised remote ID
|
||||
// or a person display name captured from a remote item's People field.
|
||||
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType string) (string, error) {
|
||||
idOrName = strings.TrimSpace(idOrName)
|
||||
if idOrName == "" || e == nil {
|
||||
return "", nil
|
||||
}
|
||||
if IsEmbyRemoteID(idOrName) {
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
if e.remote != nil {
|
||||
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
|
||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||
var imageInfoTypes = []string{"Primary", "Backdrop"}
|
||||
|
||||
// ImageInfos 返回条目的图片清单,对应 Emby 的 GET /Items/{Id}/Images。
|
||||
// 客户端用它在详情页决定要加载哪些图;缺失该接口会落到 404,部分客户端
|
||||
// 因此把条目当成"无图"而放弃渲染海报。
|
||||
func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]any {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, 2)
|
||||
seen := map[string]bool{}
|
||||
for _, imageType := range imageInfoTypes {
|
||||
raw, err := e.ImageURL(ctx, id, imageType)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
// 非 Backdrop 类型在缺图时会回退到主图,去重避免同一张图重复出现。
|
||||
if raw == "" || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
out = append(out, map[string]any{
|
||||
"ImageType": imageType,
|
||||
"ImageIndex": 0,
|
||||
"ImageTag": id,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserAvatarURL 返回用户头像的来源地址;用户未设置头像时返回空串。
|
||||
func (e *EmbyService) UserAvatarURL(ctx context.Context, userID string) string {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || e == nil || e.repo == nil || e.repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
user, err := e.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(user.AvatarURL)
|
||||
}
|
||||
|
||||
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
|
||||
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
|
||||
if e == nil || strings.TrimSpace(id) == "" {
|
||||
|
||||
@@ -61,6 +61,9 @@ type EmbyService struct {
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
|
||||
peopleMu sync.RWMutex
|
||||
peopleCache map[string]embyPeopleCacheEntry
|
||||
}
|
||||
|
||||
// NewEmbyService is the constructor.
|
||||
@@ -117,6 +120,18 @@ const (
|
||||
embyVirtualCacheTTL = 10 * time.Minute
|
||||
embyVisibilityCacheTTL = 30 * time.Second
|
||||
embySeriesGroupingLimit = maxMediaSearchLimit
|
||||
// Virtual artwork used to be wiped entirely once the in-memory map crossed
|
||||
// a few thousand entries. A homepage refresh asks Latest for every library
|
||||
// at once, so that wipe dropped the series the client was about to paint.
|
||||
// Caps are sized for that fan-out; overflow evicts the oldest entries only.
|
||||
embyVirtualSeriesCap = 8000
|
||||
embyVirtualSeasonCap = 16000
|
||||
embyVirtualArtworkCap = 24000
|
||||
// Clients that already cached the 1x1 placeholder treat a stable tag as
|
||||
// immutable. Virtual ids are the ones that served that placeholder, so
|
||||
// only those tags get a suffix that forces a refetch.
|
||||
embyVirtualPrimaryTagSuffix = "-p2"
|
||||
embyVirtualBackdropTagSuffix = "-bd2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -131,6 +146,13 @@ type embyVisibilityCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
|
||||
// list refresh. TV clients commonly request the same posters/items repeatedly.
|
||||
type embyPeopleCacheEntry struct {
|
||||
people []map[string]any
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
|
||||
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
|
||||
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
|
||||
|
||||
@@ -3,12 +3,24 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
|
||||
var cached embyCountsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return map[string]any{
|
||||
"MovieCount": cached.MovieCount,
|
||||
"SeriesCount": int(cached.SeriesCount),
|
||||
"EpisodeCount": cached.EpisodeCount,
|
||||
"ItemCount": cached.ItemCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
return e.applyUserMediaVisibility(ctx, q, userID)
|
||||
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
|
||||
MovieCount: movieCount,
|
||||
SeriesCount: int64(seriesCount),
|
||||
EpisodeCount: episodeCount,
|
||||
ItemCount: itemCount,
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return map[string]any{
|
||||
"MovieCount": movieCount,
|
||||
"SeriesCount": seriesCount,
|
||||
|
||||
@@ -9,13 +9,22 @@ import (
|
||||
)
|
||||
|
||||
type embyItemsCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyLatestCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Items []map[string]any `json:"items"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyCountsCacheValue struct {
|
||||
MovieCount int64 `json:"movie_count"`
|
||||
SeriesCount int64 `json:"series_count"`
|
||||
EpisodeCount int64 `json:"episode_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
@@ -43,10 +52,25 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
return "media:emby:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
|
||||
const defaultEmbyLatestCacheTTLSeconds = 300
|
||||
|
||||
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
|
||||
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
|
||||
// 观察到 73 个并发),缓存一旦集中过期,这批请求会同时穿透并各自重建
|
||||
// payload。延长后稳态下几乎全部命中缓存,冷启动频率也随之下降。
|
||||
func (e *EmbyService) embyLatestCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.EmbyLatestTTLSeconds < 1 {
|
||||
return defaultEmbyLatestCacheTTLSeconds
|
||||
}
|
||||
return e.cfg.Cache.EmbyLatestTTLSeconds
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
|
||||
return 90
|
||||
|
||||
@@ -132,15 +132,16 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return cached.Items, nil
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if parentID != "" {
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err == nil && e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -166,12 +167,12 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
return nil, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
@@ -180,7 +181,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||
@@ -191,7 +192,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
for _, group := range groups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return items, nil
|
||||
return items, e.artworkRefsForSeriesGroups(groups), nil
|
||||
}
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
@@ -519,11 +520,11 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
if seriesID != "" {
|
||||
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
|
||||
if sEntry.posterURL != "" {
|
||||
item["SeriesPrimaryImageTag"] = seriesID
|
||||
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
|
||||
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
|
||||
item["ParentBackdropItemId"] = seriesID
|
||||
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
|
||||
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(seriesID, embyVirtualBackdropTagSuffix)}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -537,6 +538,14 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
cacheKey := strings.TrimSpace(m.ID)
|
||||
if cacheKey == "" {
|
||||
cacheKey = strings.ToLower(filepath.Clean(m.Path))
|
||||
}
|
||||
if people, ok := e.cachedMediaPeople(cacheKey); ok {
|
||||
return people
|
||||
}
|
||||
|
||||
dir := filepath.Dir(m.Path)
|
||||
candidates := make([]string, 0, 6)
|
||||
seenPath := map[string]struct{}{}
|
||||
@@ -609,9 +618,48 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
}
|
||||
}
|
||||
}
|
||||
e.rememberMediaPeople(cacheKey, people)
|
||||
return people
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
e.peopleMu.RLock()
|
||||
entry, ok := e.peopleCache[key]
|
||||
e.peopleMu.RUnlock()
|
||||
if !ok || now.After(entry.expiresAt) {
|
||||
if ok {
|
||||
e.peopleMu.Lock()
|
||||
delete(e.peopleCache, key)
|
||||
e.peopleMu.Unlock()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, len(entry.people))
|
||||
copy(out, entry.people)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
e.peopleMu.Lock()
|
||||
defer e.peopleMu.Unlock()
|
||||
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
|
||||
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
|
||||
}
|
||||
stored := make([]map[string]any, len(people))
|
||||
copy(stored, people)
|
||||
e.peopleCache[key] = embyPeopleCacheEntry{
|
||||
people: stored,
|
||||
expiresAt: time.Now().Add(embyVirtualCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
orderIncludesDirection = false
|
||||
case "premieredate", "productionyear":
|
||||
order = mediaReleaseOrderSQL(desc)
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "dateplayed":
|
||||
order = "resume.watched_at"
|
||||
orderIncludesDirection = false
|
||||
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if libraryID != "" {
|
||||
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
|
||||
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(pageGroups))
|
||||
for _, group := range pageGroups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
|
||||
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
|
||||
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
|
||||
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
|
||||
apply := func(q *gorm.DB) *gorm.DB {
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
if err := movieQ.Find(&movieRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
|
||||
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
|
||||
// 查询并把响应时间浪费在用户根本看不到的条目上。
|
||||
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
|
||||
|
||||
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
|
||||
type entry struct {
|
||||
sortAt time.Time
|
||||
payload map[string]any
|
||||
sortAt time.Time
|
||||
media *model.Media
|
||||
group *embySeriesGroup
|
||||
}
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
||||
for _, g := range seriesGroups {
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
|
||||
for i := range seriesGroups {
|
||||
group := &seriesGroups[i]
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
|
||||
}
|
||||
for _, item := range movieItems {
|
||||
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
|
||||
for i := range movieRows {
|
||||
media := &movieRows[i]
|
||||
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].sortAt.After(entries[j].sortAt)
|
||||
})
|
||||
total := len(entries)
|
||||
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
|
||||
pageMovies := make([]model.Media, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
items = append(items, en.payload)
|
||||
if en.media != nil {
|
||||
pageMovies = append(pageMovies, *en.media)
|
||||
}
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadByID := make(map[string]map[string]any, len(moviePayloads))
|
||||
for _, item := range moviePayloads {
|
||||
if id, ok := item["Id"].(string); ok {
|
||||
payloadByID[id] = item
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
pageGroups := make([]embySeriesGroup, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
switch {
|
||||
case en.group != nil:
|
||||
pageGroups = append(pageGroups, *en.group)
|
||||
items = append(items, e.seriesPayload(*en.group))
|
||||
case en.media != nil:
|
||||
if item := payloadByID[en.media.ID]; item != nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
|
||||
|
||||
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
|
||||
http *http.Client
|
||||
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
||||
cache *RuntimeCacheService
|
||||
|
||||
personMu sync.RWMutex
|
||||
personImages map[string]embyRemotePersonImageRef
|
||||
}
|
||||
|
||||
type embyRemotePersonImageRef struct {
|
||||
accountID string
|
||||
remoteID string
|
||||
}
|
||||
|
||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||
@@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
||||
return nil, err
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.rememberRemotePeople(mount, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
@@ -959,6 +969,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
|
||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||
}
|
||||
|
||||
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
|
||||
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
|
||||
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
|
||||
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
|
||||
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
|
||||
return
|
||||
}
|
||||
people := remotePeopleMaps(payload["People"])
|
||||
if len(people) == 0 {
|
||||
return
|
||||
}
|
||||
r.personMu.Lock()
|
||||
defer r.personMu.Unlock()
|
||||
if r.personImages == nil || len(r.personImages) > 20000 {
|
||||
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
|
||||
}
|
||||
for _, person := range people {
|
||||
name := strings.TrimSpace(remoteItemString(person, "Name"))
|
||||
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
|
||||
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
|
||||
continue
|
||||
}
|
||||
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
|
||||
accountID: mount.AccountID,
|
||||
remoteID: remoteID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
|
||||
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
r.personMu.RLock()
|
||||
ref, ok := r.personImages[key]
|
||||
r.personMu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
acct := r.AccountByID(ctx, ref.accountID)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
func remotePeopleMaps(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if person, ok := item.(map[string]any); ok {
|
||||
out = append(out, person)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||
|
||||
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
|
||||
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
|
||||
if people, ok := m["People"]; ok {
|
||||
RewriteEmbyRemoteIDs(people, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
"Items": []any{
|
||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||
},
|
||||
"People": []any{
|
||||
map[string]any{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||
t.Fatalf("nested Id = %v", nested["Id"])
|
||||
}
|
||||
person := payload["People"].([]any)[0].(map[string]any)
|
||||
if person["Id"] != "embyremote~acct-1~person-1" {
|
||||
t.Fatalf("person Id = %v", person["Id"])
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
|
||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Id": "item-1",
|
||||
"Name": "测试电影",
|
||||
"People": []map[string]any{
|
||||
{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "user-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-people"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
|
||||
|
||||
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "People") {
|
||||
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
|
||||
}
|
||||
people, ok := out["People"].([]any)
|
||||
if !ok || len(people) != 1 {
|
||||
t.Fatalf("People = %#v, want one person", out["People"])
|
||||
}
|
||||
person := people[0].(map[string]any)
|
||||
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
|
||||
t.Fatalf("person Id = %v, want encoded remote id", got)
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
|
||||
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
|
||||
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -37,11 +38,7 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
if len(e.virtualSeries) > 2000 || len(e.virtualSeasons) > 5000 || len(e.virtualArtwork) > 7000 {
|
||||
e.virtualSeries = make(map[string]embySeriesCacheEntry)
|
||||
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
e.trimVirtualCachesLocked(time.Now())
|
||||
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
|
||||
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
|
||||
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
|
||||
@@ -65,6 +62,7 @@ func (e *EmbyService) rememberSeasonGroup(season embySeasonGroup) {
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
e.trimVirtualCachesLocked(time.Now())
|
||||
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
|
||||
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
|
||||
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
|
||||
@@ -135,3 +133,123 @@ func (e *EmbyService) cachedArtworkURL(id, imageType string) (string, bool) {
|
||||
}
|
||||
return entry.backdrop, entry.backdrop != ""
|
||||
}
|
||||
|
||||
// embyArtworkRef is the poster/backdrop pair stored next to a Latest payload
|
||||
// so a JSON-cache hit can refill the in-memory artwork map before the client
|
||||
// asks for the image.
|
||||
type embyArtworkRef struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) artworkRefsForSeriesGroups(groups []embySeriesGroup) map[string]embyArtworkRef {
|
||||
if e == nil || len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := make(map[string]embyArtworkRef, len(groups)*2)
|
||||
for _, group := range groups {
|
||||
if group.PosterURL == "" && group.BackdropURL == "" {
|
||||
continue
|
||||
}
|
||||
ref := embyArtworkRef{Primary: group.PosterURL, Backdrop: group.BackdropURL}
|
||||
refs[group.ID] = ref
|
||||
for _, season := range e.seasonsForSeries(group) {
|
||||
refs[season.ID] = ref
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberArtworkRefs(refs map[string]embyArtworkRef) {
|
||||
if e == nil || len(refs) == 0 {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(embyVirtualCacheTTL)
|
||||
e.virtualMu.Lock()
|
||||
defer e.virtualMu.Unlock()
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(refs))
|
||||
}
|
||||
e.trimVirtualArtworkLocked(time.Now())
|
||||
for id, ref := range refs {
|
||||
if strings.TrimSpace(id) == "" || (ref.Primary == "" && ref.Backdrop == "") {
|
||||
continue
|
||||
}
|
||||
e.virtualArtwork[id] = embyArtworkCacheEntry{primary: ref.Primary, backdrop: ref.Backdrop, expiresAt: expiresAt}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualCachesLocked(now time.Time) {
|
||||
e.trimVirtualSeriesLocked(now)
|
||||
e.trimVirtualSeasonsLocked(now)
|
||||
e.trimVirtualArtworkLocked(now)
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualSeriesLocked(now time.Time) {
|
||||
for id, entry := range e.virtualSeries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualSeries, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualSeries, embyVirtualSeriesCap, func(entry embySeriesCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualSeasonsLocked(now time.Time) {
|
||||
for id, entry := range e.virtualSeasons {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualSeasons, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualSeasons, embyVirtualSeasonCap, func(entry embySeasonCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualArtworkLocked(now time.Time) {
|
||||
for id, entry := range e.virtualArtwork {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualArtwork, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualArtwork, embyVirtualArtworkCap, func(entry embyArtworkCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
// evictOldest drops the soonest-expiring entries until the map is under cap.
|
||||
// It must not replace the map: a homepage refresh remembers many series at
|
||||
// once, and wiping the whole cache made the just-advertised backdrops 404
|
||||
// into a 1x1 placeholder.
|
||||
func evictOldest[T any](items map[string]T, cap int, expiresAt func(T) time.Time) {
|
||||
excess := len(items) - cap
|
||||
if cap <= 0 || excess <= 0 {
|
||||
return
|
||||
}
|
||||
type pair struct {
|
||||
id string
|
||||
at time.Time
|
||||
}
|
||||
ordered := make([]pair, 0, len(items))
|
||||
for id, entry := range items {
|
||||
ordered = append(ordered, pair{id: id, at: expiresAt(entry)})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].at.Before(ordered[j].at) })
|
||||
if excess > len(ordered) {
|
||||
excess = len(ordered)
|
||||
}
|
||||
for i := 0; i < excess; i++ {
|
||||
delete(items, ordered[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
func embyVirtualImageTag(id, suffix string) string {
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) || strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
return id + suffix
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -296,6 +296,71 @@ func TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyVirtualSeriesArtworkRebuildsAfterMemoryDrop(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
svc.cache = NewRuntimeCacheService(nil, nil)
|
||||
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "ep-hero"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "树海之魔",
|
||||
Path: `/media/anime/树海之魔/Season 01/树海之魔 - S01E01.mkv`,
|
||||
PosterURL: `/poster.jpg`,
|
||||
BackdropURL: `/backdrop.jpg`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
items, err := svc.LatestItems(t.Context(), "", lib.ID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("latest items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("latest len = %d, want 1", len(items))
|
||||
}
|
||||
seriesID, _ := items[0]["Id"].(string)
|
||||
tags, _ := items[0]["BackdropImageTags"].([]string)
|
||||
if seriesID == "" || len(tags) != 1 || tags[0] != seriesID+embyVirtualBackdropTagSuffix {
|
||||
t.Fatalf("hero item should advertise a cache-busted backdrop tag, got id=%q tags=%#v", seriesID, items[0]["BackdropImageTags"])
|
||||
}
|
||||
|
||||
svc.virtualMu.Lock()
|
||||
svc.virtualArtwork = nil
|
||||
svc.virtualSeries = nil
|
||||
svc.virtualSeasons = nil
|
||||
svc.virtualMu.Unlock()
|
||||
|
||||
backdrop, err := svc.ImageURL(t.Context(), seriesID, "Backdrop")
|
||||
if err != nil {
|
||||
t.Fatalf("backdrop after memory drop: %v", err)
|
||||
}
|
||||
if backdrop != "/backdrop.jpg" {
|
||||
t.Fatalf("backdrop = %q, want rebuilt backdrop", backdrop)
|
||||
}
|
||||
|
||||
svc.virtualMu.Lock()
|
||||
svc.virtualArtwork = nil
|
||||
svc.virtualMu.Unlock()
|
||||
if _, err := svc.LatestItems(t.Context(), "", lib.ID, 5); err != nil {
|
||||
t.Fatalf("cached latest: %v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
backdrop, err = svc.ImageURL(cancelled, seriesID, "Backdrop")
|
||||
if err != nil {
|
||||
t.Fatalf("backdrop from rewarmed cache: %v", err)
|
||||
}
|
||||
if backdrop != "/backdrop.jpg" {
|
||||
t.Fatalf("rewarmed backdrop = %q, want cached backdrop", backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true}
|
||||
@@ -429,13 +494,13 @@ func TestInferSeriesNameFromPath(t *testing.T) {
|
||||
want: "间谍过家家",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
@@ -513,3 +578,45 @@ func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
|
||||
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesLibraryListUsesRuntimeCache(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
svc.cache = NewRuntimeCacheService(nil, nil)
|
||||
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("cache-ep-%d", i)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "缓存测试番",
|
||||
Path: fmt.Sprintf(`/media/anime/缓存测试番/Season 01/缓存测试番.S01E%02d.mkv`, i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("first items call: %v", err)
|
||||
}
|
||||
if first["TotalRecordCount"] != 1 {
|
||||
t.Fatalf("first series total = %#v, want 1", first["TotalRecordCount"])
|
||||
}
|
||||
if err := svc.repo.DB.Unscoped().Where("library_id = ?", lib.ID).Delete(&model.Media{}).Error; err != nil {
|
||||
t.Fatalf("delete media: %v", err)
|
||||
}
|
||||
|
||||
second, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("second items call: %v", err)
|
||||
}
|
||||
items, _ := second["Items"].([]map[string]any)
|
||||
if second["TotalRecordCount"] != 1 || len(items) != 1 {
|
||||
t.Fatalf("cached series list = %#v, want the first response", second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,28 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
imageTags := map[string]string{}
|
||||
backdropTags := []string{}
|
||||
if group.PosterURL != "" {
|
||||
imageTags["Primary"] = group.ID
|
||||
imageTags["Primary"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if group.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, group.ID+"-bd")
|
||||
if group.BackdropURL != "" || group.PosterURL != "" {
|
||||
backdropTags = append(backdropTags, embyVirtualImageTag(group.ID, embyVirtualBackdropTagSuffix))
|
||||
}
|
||||
lastMediaAdded := group.DateLastMediaAdded
|
||||
if lastMediaAdded.IsZero() {
|
||||
lastMediaAdded = group.CreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
lastMediaAdded := group.DateLastMediaAdded
|
||||
if lastMediaAdded.IsZero() {
|
||||
lastMediaAdded = group.CreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
@@ -39,7 +39,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if group.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = group.ID
|
||||
item["PrimaryImageTag"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
@@ -52,10 +52,10 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
imageTags := map[string]string{}
|
||||
backdropTags := []string{}
|
||||
if season.Series.PosterURL != "" {
|
||||
imageTags["Primary"] = season.ID
|
||||
imageTags["Primary"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, season.ID+"-bd")
|
||||
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
|
||||
backdropTags = append(backdropTags, embyVirtualImageTag(season.ID, embyVirtualBackdropTagSuffix))
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": season.ID,
|
||||
@@ -75,12 +75,12 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if season.Series.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = season.ID
|
||||
item["SeriesPrimaryImageTag"] = season.Series.ID
|
||||
item["PrimaryImageTag"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
|
||||
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(season.Series.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
|
||||
item["ParentBackdropItemId"] = season.Series.ID
|
||||
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
|
||||
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(season.Series.ID, embyVirtualBackdropTagSuffix)}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -35,6 +36,12 @@ type ImageProxy struct {
|
||||
cacheDir string
|
||||
mu sync.Mutex
|
||||
|
||||
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
|
||||
// poster grids in bursts; letting every request decode a source image at
|
||||
// once causes CPU and memory spikes that make the whole UI feel sluggish.
|
||||
resizeSemMu sync.Mutex
|
||||
resizeSem chan struct{}
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
@@ -55,6 +62,7 @@ type ImageProxy struct {
|
||||
const (
|
||||
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||||
imagePlaceholderCacheControl = "no-store"
|
||||
imageMaxResizeConcurrency = 4
|
||||
)
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
@@ -64,6 +72,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
}
|
||||
proxy.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
@@ -181,6 +190,20 @@ func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
|
||||
return p.allowedHostsCache[host]
|
||||
}
|
||||
|
||||
// imageResizeConcurrency keeps decode/resize concurrency within the number
|
||||
// of CPU threads the process is allowed to use, capped to avoid large
|
||||
// temporary RGBA buffers on tiny hosts.
|
||||
func imageResizeConcurrency() int {
|
||||
n := runtime.GOMAXPROCS(0)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > imageMaxResizeConcurrency {
|
||||
n = imageMaxResizeConcurrency
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -55,26 +56,32 @@ func (p *ImageProxy) RemoveFailed(raw string) error {
|
||||
// Serve writes the requested image to w. Caller is expected to validate
|
||||
// the JWT before invoking it.
|
||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
// Emby 客户端用 maxWidth / maxHeight / quality 请求缩略图。不解析这些
|
||||
// 参数就会把多兆字节的原图发给客户端,移动端往往在下载中途超时。
|
||||
opts := parseImageResizeOptions(r)
|
||||
if isLocalImagePath(raw) {
|
||||
return p.serveLocalImage(w, r, raw)
|
||||
return p.serveLocalImage(w, r, raw, opts)
|
||||
}
|
||||
return p.serveRemoteImage(ctx, w, r, raw)
|
||||
return p.serveRemoteImage(ctx, w, r, raw, opts)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
path := filepath.Clean(raw)
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil || !p.isAllowedLocalPath(abs) {
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
if opts.active() && p.serveResizedFromFile(w, r, abs, opts) {
|
||||
return nil
|
||||
}
|
||||
if !serveImageFile(w, r, filepath.Base(abs), abs, imageBrowserCacheControl) {
|
||||
servePlaceholder(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,14 +90,14 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
forceRefresh := r.URL.Query().Get("refresh") != ""
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if !forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
// No negative caching: a previously failed fetch is retried on every
|
||||
// subsequent request, so the image recovers as soon as upstream does.
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
|
||||
if err != nil {
|
||||
if forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
@@ -100,6 +107,10 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 上游原图已落盘,缩放结果复用同一条缓存流水线。
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
if contentLength != "" {
|
||||
w.Header().Set("Content-Length", contentLength)
|
||||
@@ -114,13 +125,48 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveCachedImage 在远程原图已缓存时提供服务。请求带缩放参数时优先命中
|
||||
// 缩放缓存,未命中则从已缓存的原图生成一份;缩放不可用时退回原图直出。
|
||||
func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, key, cachePath string, opts imageResizeOptions) bool {
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return true
|
||||
}
|
||||
return serveCachedImageFile(w, r, key, cachePath)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
|
||||
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
|
||||
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctype := detectContentType(data)
|
||||
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
|
||||
stat, err := file.Stat()
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
headerSize := 512
|
||||
if stat.Size() < int64(headerSize) {
|
||||
headerSize = int(stat.Size())
|
||||
}
|
||||
header := make([]byte, headerSize)
|
||||
n, readErr := io.ReadFull(file, header)
|
||||
_ = file.Close()
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
header = header[:n]
|
||||
// A transparent placeholder is exactly 67 bytes; checking the header alone
|
||||
// is enough for the normal image cache entries (they are much larger but
|
||||
// detectContentType only inspects the same leading 512 bytes anyway).
|
||||
// Close the handle before deleting: Windows refuses to delete an open file.
|
||||
if n > 0 && isImageContentType(detectContentType(header)) &&
|
||||
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "golang.org/x/image/bmp" // register BMP decoder for .bmp/.tbn sidecar art
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for poster art
|
||||
)
|
||||
|
||||
const (
|
||||
imageResizeDefaultQuality = 90
|
||||
imageResizeMinQuality = 1
|
||||
imageResizeMaxQuality = 100
|
||||
|
||||
// imageResizeMaxSourcePixels 限制参与缩放的原图像素总量。解码一张
|
||||
// N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图
|
||||
// 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接
|
||||
// 回退为原图直出,宁可不缩放也不冒 OOM 风险。
|
||||
imageResizeMaxSourcePixels = 30_000_000
|
||||
|
||||
// imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放,
|
||||
// 便于单独清理且不与原始字节流缓存互相覆盖。
|
||||
imageResizeCacheSubdir = "resized"
|
||||
)
|
||||
|
||||
// imageResizeOptions 描述客户端通过图片 URL 查询参数请求的目标尺寸。
|
||||
// Emby / Infuse / Yamby / RodelPlayer 等客户端普遍使用
|
||||
// maxWidth / maxHeight / quality,也有客户端使用 width / height。
|
||||
type imageResizeOptions struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// active 报告是否需要缩放。没有任何尺寸参数时返回 false,调用方保持
|
||||
// 原有的原图直出路径(支持 Range / ETag,行为完全不变)。
|
||||
func (o imageResizeOptions) active() bool {
|
||||
return o.MaxWidth > 0 || o.MaxHeight > 0
|
||||
}
|
||||
|
||||
// encodingQuality 返回生效的 JPEG 编码质量,缺省 90。
|
||||
func (o imageResizeOptions) encodingQuality() int {
|
||||
q := o.Quality
|
||||
if q < imageResizeMinQuality || q > imageResizeMaxQuality {
|
||||
return imageResizeDefaultQuality
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// parseImageResizeOptions 从请求查询串解析缩放参数。Emby 客户端对参数名
|
||||
// 大小写不敏感,这里逐项做 EqualFold 匹配。
|
||||
func parseImageResizeOptions(r *http.Request) imageResizeOptions {
|
||||
if r == nil || r.URL == nil {
|
||||
return imageResizeOptions{}
|
||||
}
|
||||
q := r.URL.Query()
|
||||
return imageResizeOptions{
|
||||
MaxWidth: firstPositiveQueryInt(q, "maxWidth", "width"),
|
||||
MaxHeight: firstPositiveQueryInt(q, "maxHeight", "height"),
|
||||
Quality: firstPositiveQueryInt(q, "quality"),
|
||||
}
|
||||
}
|
||||
|
||||
// firstPositiveQueryInt 按顺序返回第一个能解析为正数的查询参数。
|
||||
func firstPositiveQueryInt(q url.Values, names ...string) int {
|
||||
for _, name := range names {
|
||||
for key, values := range q {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
// Emby 客户端偶发传入 "400.0" 这类浮点字面量。
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fitSize 按等比缩放把 srcW x srcH 装进 maxW/maxH 边界,且从不放大。
|
||||
func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return srcW, srcH
|
||||
}
|
||||
scale := 1.0
|
||||
if maxW > 0 && srcW > maxW {
|
||||
scale = math.Min(scale, float64(maxW)/float64(srcW))
|
||||
}
|
||||
if maxH > 0 && srcH > maxH {
|
||||
scale = math.Min(scale, float64(maxH)/float64(srcH))
|
||||
}
|
||||
if scale >= 1 {
|
||||
return srcW, srcH
|
||||
}
|
||||
dstW := int(math.Round(float64(srcW) * scale))
|
||||
dstH := int(math.Round(float64(srcH) * scale))
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
return dstW, dstH
|
||||
}
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
// 像素后统一转 JPEG —— 客户端本来就只按缩略图显示。
|
||||
if strings.EqualFold(format, "png") && !isOpaqueImage(dst) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/png", false, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: o.encodingQuality()}); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", false, nil
|
||||
}
|
||||
|
||||
// isOpaqueImage 逐像素确认图像不含透明像素。
|
||||
func isOpaqueImage(img *image.RGBA) bool {
|
||||
bounds := img.Bounds()
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 +
|
||||
// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。
|
||||
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality())
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (p *ImageProxy) resizeCachePath(key string) string {
|
||||
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
|
||||
}
|
||||
|
||||
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
|
||||
// the caller should fall back to the original image instead of blocking after
|
||||
// the request has already been canceled.
|
||||
func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
case <-ctx.Done():
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||
func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request, srcPath string, o imageResizeOptions) bool {
|
||||
stat, err := os.Stat(srcPath)
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
return false
|
||||
}
|
||||
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
|
||||
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
|
||||
key := o.resizeCacheKey(srcPath, stat)
|
||||
cachePath := p.resizeCachePath(key)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
release, ok := p.acquireResizeSlot(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer release()
|
||||
|
||||
// 等待并发槽期间,别的请求可能已经生成了同一张缩略图。
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, o)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if unchanged {
|
||||
// 原图已经在目标尺寸内:直接流式输出,保留 ETag / Range 语义。
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
p.writeResizeCache(cachePath, out)
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||
http.ServeContent(w, r, key, stat.ModTime(), bytes.NewReader(out))
|
||||
return true
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||
dir := filepath.Dir(cachePath)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
p.warn("imageproxy: resize cache mkdir failed", err)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
tmp, err := os.CreateTemp(dir, "resized-*.tmp")
|
||||
if err != nil {
|
||||
p.warn("imageproxy: resize cache temp failed", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), cachePath); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImageProxy) warn(msg string, err error) {
|
||||
if p == nil || p.log == nil {
|
||||
return
|
||||
}
|
||||
p.log.Warn(msg, zap.Error(err))
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
||||
func encodeTestPNG(t *testing.T, w, h int, alpha uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: alpha})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsReadsEmbyParams(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/emby/Items/x/Images/Primary?maxWidth=400&maxHeight=600&quality=80", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 400 || o.MaxHeight != 600 || o.Quality != 80 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
if !o.active() {
|
||||
t.Fatal("expected options to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsIsCaseInsensitive(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?MaxWidth=250&QUALITY=70", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 250 {
|
||||
t.Fatalf("MaxWidth = %d, want 250", o.MaxWidth)
|
||||
}
|
||||
if o.Quality != 70 {
|
||||
t.Fatalf("Quality = %d, want 70", o.Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsAcceptsWidthAndHeightAliases(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?width=320&height=180", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 320 || o.MaxHeight != 180 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsInactiveWithoutDimensions(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?quality=90&tag=abc", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.active() {
|
||||
t.Fatalf("expected inactive options, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitSizePreservesAspectAndNeverUpscales(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
srcW, srcH, maxW, maxH int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{"max-width-only", 529, 911, 400, 0, 400, 689},
|
||||
{"max-height-only", 529, 911, 0, 500, 290, 500},
|
||||
{"never-upscales", 529, 911, 2000, 2000, 529, 911},
|
||||
{"both-bounds", 1000, 500, 400, 400, 400, 200},
|
||||
{"exact-size", 400, 600, 400, 600, 400, 600},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotW, gotH := fitSize(tc.srcW, tc.srcH, tc.maxW, tc.maxH)
|
||||
if gotW != tc.wantW || gotH != tc.wantH {
|
||||
t.Fatalf("fitSize(%d,%d,%d,%d) = %dx%d, want %dx%d",
|
||||
tc.srcW, tc.srcH, tc.maxW, tc.maxH, gotW, gotH, tc.wantW, tc.wantH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 529, 911, 255)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if cfg.Width != 400 || cfg.Height != 689 {
|
||||
t.Fatalf("resized to %dx%d, want 400x689", cfg.Width, cfg.Height)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
// 这里只断言"重新编码生效且输出可解码"。合成图的压缩率不代表真实海报
|
||||
// (规则色块 PNG 极小,而 JPEG 压规则图案反而更大);真实海报的体积
|
||||
// 收益在服务器上用线上素材实测。
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected a non-empty resized image")
|
||||
}
|
||||
if len(out) == len(data) {
|
||||
t.Fatal("expected the resized image to be re-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataKeepsTransparencyAsPNG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 600, 900, 128)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 300, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/png" {
|
||||
t.Fatalf("content type = %q, want image/png so transparency is preserved", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if format != "png" {
|
||||
t.Fatalf("format = %q, want png", format)
|
||||
}
|
||||
if cfg.Width != 300 || cfg.Height != 450 {
|
||||
t.Fatalf("resized to %dx%d, want 300x450", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsCacheKeyVariesWithParametersAndSource(t *testing.T) {
|
||||
stat, err := os.Stat("image_resize_test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
base := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
if base.resizeCacheKey("a", stat) != base.resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must be stable for identical inputs")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == base.resizeCacheKey("b", stat) {
|
||||
t.Fatal("cache key must differ between sources")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 200, Quality: 90}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when max width changes")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 400, Quality: 60}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when quality changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsEncodingQualityFallsBackToDefault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{0, imageResizeDefaultQuality},
|
||||
{-5, imageResizeDefaultQuality},
|
||||
{500, imageResizeDefaultQuality},
|
||||
{1, 1},
|
||||
{100, 100},
|
||||
{75, 75},
|
||||
} {
|
||||
if got := (imageResizeOptions{Quality: tc.in}).encodingQuality(); got != tc.want {
|
||||
t.Fatalf("encodingQuality(%d) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileCachesScaledResult(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaDir := dir + string(os.PathSeparator) + "media"
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
src := mediaDir + string(os.PathSeparator) + "poster.png"
|
||||
if err := os.WriteFile(src, encodeTestPNG(t, 529, 911, 255), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
opts := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(first, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected serveResizedFromFile to handle the request")
|
||||
}
|
||||
if got := first.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", got)
|
||||
}
|
||||
|
||||
// 第二次请求应命中磁盘缓存,返回与首次完全相同的字节。
|
||||
second := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected second call to be served")
|
||||
}
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
|
||||
t.Fatal("expected the cached scaled image to be reused")
|
||||
}
|
||||
if len(first.Body.Bytes()) == 0 {
|
||||
t.Fatal("expected a non-empty body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaDir := dir + string(os.PathSeparator) + "media"
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
src := mediaDir + string(os.PathSeparator) + "poster.png"
|
||||
original := encodeTestPNG(t, 529, 911, 255)
|
||||
if err := os.WriteFile(src, original, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
opts := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(first, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected first call to be served")
|
||||
}
|
||||
|
||||
stat, err := os.Stat(src)
|
||||
if err != nil {
|
||||
t.Fatalf("stat source: %v", err)
|
||||
}
|
||||
// Same size and mtime keep the resize cache key stable, but the source is
|
||||
// now invalid image data. A correct implementation serves the cached
|
||||
// thumbnail before reading/decoding the source again.
|
||||
broken := bytes.Repeat([]byte{0}, len(original))
|
||||
if err := os.WriteFile(src, broken, 0o644); err != nil {
|
||||
t.Fatalf("overwrite source: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(src, stat.ModTime(), stat.ModTime()); err != nil {
|
||||
t.Fatalf("restore mtime: %v", err)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected second call to be served from resize cache")
|
||||
}
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
|
||||
t.Fatal("expected cached thumbnail to be reused without decoding the source")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,18 @@ func mediaReleaseOrderSQL(desc bool) string {
|
||||
return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir)
|
||||
}
|
||||
|
||||
// embyMediaReleaseSortTime matches the original payload-based ordering used by
|
||||
// the Emby movie-library merge: release date, then year, then created_at.
|
||||
func embyMediaReleaseSortTime(media model.Media) time.Time {
|
||||
if t, ok := embyPremiereDate(media.ReleaseDate); ok {
|
||||
return t
|
||||
}
|
||||
if media.Year > 0 {
|
||||
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
return media.CreatedAt
|
||||
}
|
||||
|
||||
func embyPremiereDate(value string) (time.Time, bool) {
|
||||
value = normalizeReleaseDate(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -327,6 +327,22 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
return cards
|
||||
}
|
||||
|
||||
// GroupMediaSeriesItems folds episode-level rows into one representative media
|
||||
// row per series. It is intended for search surfaces where applying a small
|
||||
// limit before series grouping would otherwise return several episodes from
|
||||
// the same show.
|
||||
func GroupMediaSeriesItems(items []model.Media) []model.Media {
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) == 0 {
|
||||
return []model.Media{}
|
||||
}
|
||||
out := make([]model.Media, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
out = append(out, card.Rep)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
||||
// A theatrical feature can have local poster.jpg/background.jpg files and
|
||||
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
||||
|
||||
+25
-3
@@ -27,20 +27,32 @@ export interface DanmakuFetchResult {
|
||||
font_size: string
|
||||
area: string
|
||||
raw?: string
|
||||
/** Number of sources merged into `raw`; absent/0 means not merged. */
|
||||
merged_sources?: number
|
||||
candidates?: DanmakuAnime[]
|
||||
/**
|
||||
* Same episode, other sources. Unlike `candidates` (which means "pick one
|
||||
* before anything loads"), `alternatives` arrives together with a loaded
|
||||
* library: the backend already auto-picked one and offers the rest so the
|
||||
* user can switch without re-searching. Aggregating sources such as LogVar
|
||||
* return several libraries for the same episode.
|
||||
*/
|
||||
alternatives?: DanmakuAnime[]
|
||||
anime_title?: string
|
||||
episode_title?: string
|
||||
episode_id?: number
|
||||
match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
match_mode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||
}
|
||||
|
||||
export interface DanmakuLoadedInfo {
|
||||
animeTitle?: string
|
||||
episodeTitle?: string
|
||||
episodeId?: number | string
|
||||
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
matchMode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||
totalCount: number
|
||||
sourceType?: 'auto' | 'xml' | 'json'
|
||||
/** Number of sources merged into the loaded comments (0 = not merged). */
|
||||
mergedSources?: number
|
||||
}
|
||||
|
||||
export type DanmakuFetchOptions = {
|
||||
@@ -73,6 +85,16 @@ export const danmakuAPI = {
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}>('/danmaku/config')
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
// updateSettings persists per-user danmaku preferences (survives reload).
|
||||
updateSettings: (settings: { mergeSources: boolean }) =>
|
||||
api
|
||||
.put<{ merge_sources: boolean }>('/danmaku/settings', {
|
||||
merge_sources: settings.mergeSources,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
+12
-3
@@ -193,10 +193,18 @@ export const mediaAPI = {
|
||||
recent: (limit = 24) =>
|
||||
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||
|
||||
search: (q: string, limit = 50) =>
|
||||
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
|
||||
search: (q: string, limit = 50, options?: { groupSeries?: boolean }) =>
|
||||
api
|
||||
.get<MediaSearchPage>('/media', {
|
||||
params: {
|
||||
q,
|
||||
limit,
|
||||
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
|
||||
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
|
||||
api
|
||||
.get<MediaSearchPage>('/media', {
|
||||
params: {
|
||||
@@ -204,6 +212,7 @@ export const mediaAPI = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
group_versions: options?.groupVersions === false ? 0 : undefined,
|
||||
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||
},
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@ type DanmakuStageProps = {
|
||||
onLoaded?: (info: DanmakuLoadedInfo | null) => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
/** Called after a successful load with other libraries for the same episode. */
|
||||
onAlternatives?: (alternatives: DanmakuAnime[]) => void
|
||||
}
|
||||
|
||||
// Average of the engine's durationRange (ms). Used to compute how far a
|
||||
@@ -55,6 +57,7 @@ export function DanmakuStage({
|
||||
searchTrigger = 0,
|
||||
onLoaded,
|
||||
onCandidates,
|
||||
onAlternatives,
|
||||
}: DanmakuStageProps) {
|
||||
const holderRef = useRef<HTMLDivElement>(null)
|
||||
const managerRef = useRef<Manager<Comment> | null>(null)
|
||||
@@ -143,10 +146,13 @@ export function DanmakuStage({
|
||||
if (res.candidates && res.candidates.length > 0) {
|
||||
// 多番剧命中:交回播放器展示候选让用户选择(disambiguation)。
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
onCandidates?.(res.candidates)
|
||||
return
|
||||
}
|
||||
if (res.enabled) {
|
||||
// 弹幕已自动加载;若同一集还有其它来源,一并交回播放器供随时切换。
|
||||
onAlternatives?.(res.alternatives ?? [])
|
||||
comments = parseDanmaku(res.raw || '', res.source_type)
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
@@ -158,9 +164,11 @@ export function DanmakuStage({
|
||||
matchMode: res.match_mode,
|
||||
totalCount: comments.length,
|
||||
sourceType: res.source_type,
|
||||
mergedSources: res.merged_sources ?? 0,
|
||||
}
|
||||
} else {
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
loadedInfo = {
|
||||
totalCount: 0,
|
||||
}
|
||||
|
||||
@@ -108,35 +108,71 @@ export function LayoutHeader({
|
||||
)
|
||||
}
|
||||
|
||||
const SEARCH_PAGE_SIZE = 8
|
||||
|
||||
function LayoutHeaderSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isOpenRef = useRef(false)
|
||||
// 递增序号守卫:快速连续输入时丢弃过期响应
|
||||
const searchSeqRef = useRef(0)
|
||||
const pageRef = useRef(0)
|
||||
const loadingMoreRef = useRef(false)
|
||||
const resultsRef = useRef<Media[]>([])
|
||||
const hasMoreRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const setSearchOpen = (open: boolean) => {
|
||||
isOpenRef.current = open
|
||||
setIsOpen(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
searchSeqRef.current += 1
|
||||
pageRef.current = 0
|
||||
loadingMoreRef.current = false
|
||||
resultsRef.current = []
|
||||
hasMoreRef.current = false
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
setHasMore(false)
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++searchSeqRef.current
|
||||
pageRef.current = 0
|
||||
loadingMoreRef.current = false
|
||||
resultsRef.current = []
|
||||
hasMoreRef.current = false
|
||||
setResults([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
setLoadingMore(false)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||
if (seq !== searchSeqRef.current) return
|
||||
setResults(res.items || [])
|
||||
setIsOpen(true)
|
||||
const items = res.items || []
|
||||
const total = res.total ?? items.length
|
||||
const more = items.length < total
|
||||
pageRef.current = 1
|
||||
resultsRef.current = items
|
||||
hasMoreRef.current = more
|
||||
setResults(items)
|
||||
setHasMore(more)
|
||||
if (isOpenRef.current) setIsOpen(true)
|
||||
} catch {
|
||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
||||
// 请求失败时保持空结果,用户继续输入或滚动时会重新请求
|
||||
} finally {
|
||||
if (seq === searchSeqRef.current) setLoading(false)
|
||||
}
|
||||
@@ -148,6 +184,7 @@ function LayoutHeaderSearch() {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -155,15 +192,49 @@ function LayoutHeaderSearch() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const loadMore = async () => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return
|
||||
|
||||
const seq = searchSeqRef.current
|
||||
const nextPage = pageRef.current + 1
|
||||
loadingMoreRef.current = true
|
||||
setLoadingMore(true)
|
||||
|
||||
try {
|
||||
const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||
if (seq !== searchSeqRef.current) return
|
||||
|
||||
const incoming = res.items || []
|
||||
const currentResults = resultsRef.current
|
||||
const known = new Set(currentResults.map((item) => item.id))
|
||||
const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))]
|
||||
const total = res.total ?? nextResults.length
|
||||
const more = nextPage * SEARCH_PAGE_SIZE < total
|
||||
pageRef.current = nextPage
|
||||
resultsRef.current = nextResults
|
||||
hasMoreRef.current = more
|
||||
setResults(nextResults)
|
||||
setHasMore(more)
|
||||
} catch {
|
||||
// 请求失败时保留当前结果,继续滚动可重试
|
||||
} finally {
|
||||
if (seq === searchSeqRef.current) {
|
||||
loadingMoreRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (item: Media) => {
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
setQuery('')
|
||||
navigate(favouriteMediaLink(item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0])
|
||||
}
|
||||
@@ -180,11 +251,12 @@ function LayoutHeaderSearch() {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (e.target.value.trim()) setIsOpen(true)
|
||||
const nextQuery = e.target.value
|
||||
setQuery(nextQuery)
|
||||
setSearchOpen(Boolean(nextQuery.trim()))
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
if (query.trim()) setSearchOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索媒体…"
|
||||
@@ -198,7 +270,7 @@ function LayoutHeaderSearch() {
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
@@ -215,9 +287,20 @@ function LayoutHeaderSearch() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget
|
||||
if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) {
|
||||
void loadMore()
|
||||
}
|
||||
}}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
>
|
||||
{results.length === 0 && !loading ? (
|
||||
{results.length === 0 && loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
|
||||
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
|
||||
正在搜索…
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||
未搜索到与 “{query}” 相关的媒体内容
|
||||
</div>
|
||||
@@ -272,6 +355,17 @@ function LayoutHeaderSearch() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
|
||||
<LoaderCircle size={12} className="text-brand-500 animate-spin" />
|
||||
正在加载更多…
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && !hasMore && results.length > 0 && (
|
||||
<div className="py-2 text-center text-[10px] text-[var(--app-muted)]">
|
||||
已显示全部 {results.length} 条结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -23,6 +23,16 @@ type PlayerDanmakuPanelProps = {
|
||||
onFontSizeChange: (v: number) => void
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/**
|
||||
* Other libraries holding this same episode. Danmaku is already loaded from
|
||||
* one of them; these exist so the user can switch sources on the spot.
|
||||
*/
|
||||
alternatives: DanmakuAnime[]
|
||||
/** Per-user preference: merge the same episode's sources into one list. */
|
||||
mergeSources: boolean
|
||||
onMergeSourcesChange: (v: boolean) => void
|
||||
/** True while the merge preference is being persisted. */
|
||||
mergeSaving?: boolean
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource?: string
|
||||
/** Title used by auto-matching (e.g. anime title, media title or filename). */
|
||||
@@ -48,6 +58,10 @@ export function PlayerDanmakuPanel({
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
candidates,
|
||||
alternatives,
|
||||
mergeSources,
|
||||
onMergeSourcesChange,
|
||||
mergeSaving = false,
|
||||
selectedSource,
|
||||
autoMatchTitle,
|
||||
danmakuInfo,
|
||||
@@ -95,6 +109,12 @@ export function PlayerDanmakuPanel({
|
||||
<Tag size={10} /> 文件名匹配
|
||||
</span>
|
||||
)
|
||||
case 'metadata':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-cyan-500/30 bg-cyan-500/15 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300">
|
||||
<Search size={10} /> 刮削信息匹配
|
||||
</span>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
|
||||
@@ -114,6 +134,11 @@ export function PlayerDanmakuPanel({
|
||||
|
||||
const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual')
|
||||
|
||||
// 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。
|
||||
const alternativeRows = alternatives.flatMap((anime) =>
|
||||
anime.episodes.map((ep) => ({ anime, ep })),
|
||||
)
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
@@ -286,6 +311,83 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */}
|
||||
{enabled && alternatives.length > 0 && (
|
||||
<div className="mb-4 rounded-xl border border-sky-400/25 bg-sky-400/5 p-2.5">
|
||||
<div className="mb-1.5 px-1 text-xs font-medium text-sky-200">
|
||||
同集其它来源({alternativeRows.length})
|
||||
</div>
|
||||
<div className="mb-1.5 px-1 text-[10px] leading-relaxed text-white/45">
|
||||
当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
{alternativeRows.map(({ anime, ep }, i) => {
|
||||
const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId)
|
||||
return (
|
||||
<button
|
||||
key={`${anime.animeId}-${ep.episodeId}-${i}`}
|
||||
onClick={() => onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)}
|
||||
className={
|
||||
'mb-1 flex w-full items-start gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition ' +
|
||||
(current
|
||||
? 'bg-sky-500/20 text-white'
|
||||
: 'text-white/70 hover:bg-sky-500/15 hover:text-white')
|
||||
}
|
||||
title={`切换到《${anime.animeTitle}》的《${ep.episodeTitle}》`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{current ? (
|
||||
<Check size={12} className="text-sky-300" />
|
||||
) : (
|
||||
<Film size={12} className="text-white/35" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{anime.animeTitle || `来源 ${i + 1}`}</span>
|
||||
{ep.episodeTitle && (
|
||||
<span className="mt-0.5 block truncate text-[10px] text-white/50">
|
||||
{ep.episodeTitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{current && (
|
||||
<span className="mt-0.5 shrink-0 text-[10px] text-sky-300">当前</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */}
|
||||
{enabled && alternativeRows.length > 1 && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-400/5 p-2.5">
|
||||
<label className="flex cursor-pointer items-start justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-emerald-200">合并多来源弹幕</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-white/45">
|
||||
把以上来源的弹幕合并,并按时间与内容去重后一起显示。该设置会保存到账号。
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 pt-0.5">
|
||||
{mergeSaving && <Loader2 size={11} className="animate-spin text-emerald-300" />}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSources}
|
||||
onChange={(e) => onMergeSourcesChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-emerald-500"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
{mergeSources && danmakuInfo?.mergedSources ? (
|
||||
<div className="mt-1.5 border-t border-white/10 pt-1.5 text-[10px] text-emerald-300/80">
|
||||
已合并 {danmakuInfo.mergedSources} 个来源
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 屏幕占比(显示区域) */}
|
||||
<SliderRow
|
||||
label="屏幕占比"
|
||||
@@ -373,4 +475,4 @@ function EpisodeRow({
|
||||
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,32 @@ import { useAuthStore } from '../stores/auth'
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// token 过期前 30 秒即视为失效,给刷新留出余量。
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// isTokenExpired 判断 token 是否已过期或即将过期。
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
@@ -28,6 +54,13 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
|
||||
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
|
||||
// 让本 effect 重建连接,失败则清空会话停止重连。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
|
||||
const ws = new WebSocket(url)
|
||||
@@ -47,6 +80,11 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
reconnectAttempts += 1
|
||||
// 快速阶段保持原有线性退避,之后固定 60s 慢速重试;
|
||||
// timer 始终只有一个在途,cleanup 时统一清除,不会堆积。
|
||||
|
||||
@@ -98,10 +98,15 @@ export function PlayerPage() {
|
||||
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
|
||||
// 自动匹配歧义(多番剧命中)时的候选列表。
|
||||
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
|
||||
// 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。
|
||||
const [danmakuAlternatives, setDanmakuAlternatives] = useState<DanmakuAnime[]>([])
|
||||
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
|
||||
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
|
||||
// 用户当前选定的弹幕来源描述(面板中展示)。
|
||||
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
|
||||
// 弹幕合并偏好:从 /danmaku/config 读取(按用户落库),切换后写回并重新抓取。
|
||||
const [danmakuMergeSources, setDanmakuMergeSources] = useState(false)
|
||||
const [danmakuMergeSaving, setDanmakuMergeSaving] = useState(false)
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
@@ -168,11 +173,45 @@ export function PlayerPage() {
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持默认(不合并),不影响播放。
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
|
||||
const danmakuChangeMergeSources = useCallback((next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
danmakuAPI
|
||||
.updateSettings({ mergeSources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
// 保存失败时保持原值,用户可重试。
|
||||
})
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
}, [])
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
const searchDanmaku = useCallback((kw: string) => {
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
@@ -184,6 +223,11 @@ export function PlayerPage() {
|
||||
setDanmakuInfo(info)
|
||||
}, [])
|
||||
|
||||
// 同一集的其它来源:弹幕已自动加载好,这里只更新可切换列表。
|
||||
const danmakuGotAlternatives = useCallback((alternatives: DanmakuAnime[]) => {
|
||||
setDanmakuAlternatives(alternatives)
|
||||
}, [])
|
||||
|
||||
// 多番剧命中(disambiguation):展示候选让用户选择。
|
||||
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
|
||||
setDanmakuCandidates(candidates)
|
||||
@@ -197,6 +241,8 @@ export function PlayerPage() {
|
||||
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
|
||||
setDanmakuEpisodeId(episodeId)
|
||||
setDanmakuCandidates([])
|
||||
// 刻意不清空 danmakuAlternatives:切到别的来源后仍要能继续切换回去,
|
||||
// 否则用户每次都得重新搜一遍。
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
|
||||
@@ -207,6 +253,7 @@ export function PlayerPage() {
|
||||
const danmakuResetAuto = useCallback(() => {
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
@@ -221,6 +268,7 @@ export function PlayerPage() {
|
||||
setHlsStartSec(0)
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
@@ -947,6 +995,7 @@ export function PlayerPage() {
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
@@ -984,6 +1033,10 @@ export function PlayerPage() {
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
alternatives={danmakuAlternatives}
|
||||
mergeSources={danmakuMergeSources}
|
||||
onMergeSourcesChange={danmakuChangeMergeSources}
|
||||
mergeSaving={danmakuMergeSaving}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
autoMatchTitle={danmakuAutoTitle}
|
||||
danmakuInfo={danmakuInfo}
|
||||
|
||||
@@ -158,6 +158,7 @@ type PlayerVideoStageProps = {
|
||||
onToggleDanmaku: () => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
@@ -202,6 +203,7 @@ export function PlayerVideoStage({
|
||||
onToggleDanmaku,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
@@ -588,6 +590,7 @@ export function PlayerVideoStage({
|
||||
searchTrigger={danmakuSearchTrigger}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
onAlternatives={onDanmakuAlternatives}
|
||||
/>
|
||||
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
|
||||
<AssSubtitleStage
|
||||
|
||||
Reference in New Issue
Block a user