From 1ff60d5641d1709b49f876653e91508d00af3ba9 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:00:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=BA=94=E7=94=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=E7=95=8C=E9=9D=A2=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/defaults.go | 1 + internal/config/types.go | 5 + internal/handler/danmaku.go | 39 +- internal/handler/danmaku_settings_test.go | 135 ++++++ .../handler/emby_compat_additions_test.go | 121 +++++ internal/handler/emby_images.go | 43 ++ internal/handler/emby_routes.go | 18 + .../handler/routes_authenticated_extras.go | 1 + internal/model/user.go | 4 + .../service/danmaku_episode_match_test.go | 116 +++++ internal/service/danmaku_match_test.go | 350 ++++++++++++++- internal/service/danmaku_merge.go | 220 +++++++++ internal/service/danmaku_merge_test.go | 125 ++++++ internal/service/danmaku_service.go | 420 +++++++++++++++++- internal/service/danmaku_service_test.go | 3 +- internal/service/emby_artwork.go | 48 ++ internal/service/emby_items_cache.go | 14 + internal/service/emby_items_detail.go | 4 +- internal/service/image_proxy_remote.go | 31 +- internal/service/image_resize.go | 269 +++++++++++ internal/service/image_resize_test.go | 232 ++++++++++ web/src/api/danmaku.ts | 22 + web/src/components/DanmakuStage.tsx | 8 + web/src/components/PlayerDanmakuPanel.tsx | 96 ++++ web/src/hooks/useWebSocket.ts | 38 ++ web/src/pages/PlayerPage.tsx | 53 +++ web/src/pages/PlayerVideoStage.tsx | 3 + 27 files changed, 2377 insertions(+), 42 deletions(-) create mode 100644 internal/handler/danmaku_settings_test.go create mode 100644 internal/handler/emby_compat_additions_test.go create mode 100644 internal/service/danmaku_episode_match_test.go create mode 100644 internal/service/danmaku_merge.go create mode 100644 internal/service/danmaku_merge_test.go create mode 100644 internal/service/image_resize.go create mode 100644 internal/service/image_resize_test.go diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b7a3d6e..ca7f9f9 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -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", "") diff --git a/internal/config/types.go b/internal/config/types.go index 42a0409..6a8afc0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 { diff --git a/internal/handler/danmaku.go b/internal/handler/danmaku.go index c048bad..193c1fe 100644 --- a/internal/handler/danmaku.go +++ b/internal/handler/danmaku.go @@ -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}) } } diff --git a/internal/handler/danmaku_settings_test.go b/internal/handler/danmaku_settings_test.go new file mode 100644 index 0000000..6142537 --- /dev/null +++ b/internal/handler/danmaku_settings_test.go @@ -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") + } +} diff --git a/internal/handler/emby_compat_additions_test.go b/internal/handler/emby_compat_additions_test.go new file mode 100644 index 0000000..9b0302b --- /dev/null +++ b/internal/handler/emby_compat_additions_test.go @@ -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) + } +} diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 9f6f50b..cd974f2 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -50,6 +50,49 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { } } +// 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 + } + 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(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") diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 84a7a56..7a108d9 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -115,12 +115,24 @@ 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)) + // 用户头像。没有头像时返回带缓存头的 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 +214,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)) diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index 80e3c52..2ab6801 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -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)) diff --git a/internal/model/user.go b/internal/model/user.go index 1c7be74..ca97a58 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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. diff --git a/internal/service/danmaku_episode_match_test.go b/internal/service/danmaku_episode_match_test.go new file mode 100644 index 0000000..e689969 --- /dev/null +++ b/internal/service/danmaku_episode_match_test.go @@ -0,0 +1,116 @@ +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) + } + } +} + +// 真实数据形态:官方 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) + } +} diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index 095fc4a..bd3d2dc 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -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":"测试动画"}]}`, - `弹幕B官方`, - 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, `弹幕A`) + }) + 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, `弹幕B官方`) + }) + 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, `官方兜底弹幕`) + }) + 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, `首选来源弹幕`) + }) + 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, `官方弹幕`) + }) + 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, `唯一来源弹幕`) + }) + 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, `弹幕来自官方`) + }) + 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) { diff --git a/internal/service/danmaku_merge.go b/internal/service/danmaku_merge.go new file mode 100644 index 0000000..2a8ff07 --- /dev/null +++ b/internal/service/danmaku_merge.go @@ -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(text)两种格式。 +// 无法识别的载荷返回空列表,调用方据此跳过该来源。 +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) +} diff --git a/internal/service/danmaku_merge_test.go b/internal/service/danmaku_merge_test.go new file mode 100644 index 0000000..bfa03ca --- /dev/null +++ b/internal/service/danmaku_merge_test.go @@ -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` + + `` + 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) +} diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index c9e334b..09e0e7e 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -13,10 +13,12 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" "time" + "unicode" "go.uber.org/zap" @@ -57,6 +59,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 +81,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 @@ -177,6 +195,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 @@ -199,6 +252,11 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { // 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 的带偏好版本。 +func (s *DanmakuService) FetchWithOptions(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 +292,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,11 +314,31 @@ 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 + } } } } @@ -262,9 +346,10 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 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 @@ -276,7 +361,7 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID // 3) 现有自动识别:标题层级(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 +374,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, " 12 { + n = 12 + } + return string(a[:n]) == string(b[:n]) +} + +// 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 +} + +// 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 { + subtitle := danmakuEpisodeSubtitle(episodeTitle) + out := make([]DanmakuAnime, 0, len(candidates)) + for _, anime := range candidates { + hits := make([]DanmakuEpisode, 0, len(anime.Episodes)) + for _, ep := range anime.Episodes { + if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum { + continue + } + // 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。 + if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) { + continue + } + hits = append(hits, ep) + } + if len(hits) == 0 { + continue + } + anime.Episodes = hits + out = append(out, anime) + } + return out } diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index c77f49f..2d7a390 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -22,7 +22,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() diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index d943f59..4ba9d63 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -71,6 +71,54 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin return "", nil } +// 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) == "" { diff --git a/internal/service/emby_items_cache.go b/internal/service/emby_items_cache.go index b8d6454..7eb40cf 100644 --- a/internal/service/emby_items_cache.go +++ b/internal/service/emby_items_cache.go @@ -47,6 +47,20 @@ func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) str 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 diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 3cc35a5..e2f1e85 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -140,7 +140,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic { out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit) if err == nil && e.cache != nil { - e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second) + e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second) } return out, err } @@ -166,7 +166,7 @@ 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 } diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go index 2096337..a750775 100644 --- a/internal/service/image_proxy_remote.go +++ b/internal/service/image_proxy_remote.go @@ -55,26 +55,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 +89,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 +106,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,6 +124,15 @@ 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. if err != nil { diff --git a/internal/service/image_resize.go b/internal/service/image_resize.go new file mode 100644 index 0000000..13b65f9 --- /dev/null +++ b/internal/service/image_resize.go @@ -0,0 +1,269 @@ +package service + +import ( + "bytes" + "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") +} + +// 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 + } + 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) + } + + key := o.resizeCacheKey(srcPath, stat) + cachePath := p.resizeCachePath(key) + if serveCachedImageFile(w, r, key, cachePath) { + return true + } + 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)) +} diff --git a/internal/service/image_resize_test.go b/internal/service/image_resize_test.go new file mode 100644 index 0000000..9dbd89f --- /dev/null +++ b/internal/service/image_resize_test.go @@ -0,0 +1,232 @@ +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") + } +} diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index 69a8648..3d4c32a 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -27,7 +27,17 @@ 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 @@ -41,6 +51,8 @@ export interface DanmakuLoadedInfo { matchMode?: 'hash' | 'filename' | '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), } \ No newline at end of file diff --git a/web/src/components/DanmakuStage.tsx b/web/src/components/DanmakuStage.tsx index 23ec15a..4924141 100644 --- a/web/src/components/DanmakuStage.tsx +++ b/web/src/components/DanmakuStage.tsx @@ -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(null) const managerRef = useRef | 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, } diff --git a/web/src/components/PlayerDanmakuPanel.tsx b/web/src/components/PlayerDanmakuPanel.tsx index 0d6eec0..cbd7a31 100644 --- a/web/src/components/PlayerDanmakuPanel.tsx +++ b/web/src/components/PlayerDanmakuPanel.tsx @@ -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, @@ -114,6 +128,11 @@ export function PlayerDanmakuPanel({ const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual') + // 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。 + const alternativeRows = alternatives.flatMap((anime) => + anime.episodes.map((ep) => ({ anime, ep })), + ) + return ( // 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
)} + {/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */} + {enabled && alternatives.length > 0 && ( +
+
+ 同集其它来源({alternativeRows.length}) +
+
+ 当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。 +
+
+ {alternativeRows.map(({ anime, ep }, i) => { + const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId) + return ( + + ) + })} +
+
+ )} + + {/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */} + {enabled && alternativeRows.length > 1 && ( +
+ + {mergeSources && danmakuInfo?.mergedSources ? ( +
+ 已合并 {danmakuInfo.mergedSources} 个来源 +
+ ) : null} +
+ )} + {/* 屏幕占比(显示区域) */} = 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 时统一清除,不会堆积。 diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index d227914..8197bcd 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -98,10 +98,15 @@ export function PlayerPage() { const [danmakuEpisodeId, setDanmakuEpisodeId] = useState(null) // 自动匹配歧义(多番剧命中)时的候选列表。 const [danmakuCandidates, setDanmakuCandidates] = useState([]) + // 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。 + const [danmakuAlternatives, setDanmakuAlternatives] = useState([]) // 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。 const [danmakuInfo, setDanmakuInfo] = useState(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} diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index 273b61d..f129259 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -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 ? (