mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
支持按用户保存播放器与弹幕偏好
在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。 返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。 结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
This commit is contained in:
+12
-16
@@ -2,6 +2,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,10 +17,8 @@ import (
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
|
||||
opts := service.DanmakuFetchOptions{
|
||||
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
|
||||
}
|
||||
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
|
||||
opts := service.DanmakuFetchOptions{UserID: uid}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
@@ -30,17 +29,15 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
||||
// size, area, enabled) so the player can initialize its control panel without
|
||||
// admin privileges.
|
||||
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
|
||||
// preferences so the player can initialize without admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
|
||||
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
@@ -48,21 +45,20 @@ func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
}
|
||||
var req service.DanmakuSettingsPatch
|
||||
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"})
|
||||
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
|
||||
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,58 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) {
|
||||
t.Fatal("response must never expose the application secret")
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
t.Fatalf("unexpected service config: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"source":"ftp://dm.example.com"}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.DanmakuSource != "" {
|
||||
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -31,10 +31,17 @@ 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"`
|
||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
|
||||
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
|
||||
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
|
||||
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
|
||||
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
|
||||
DanmakuAppKey string `gorm:"size:256" json:"-"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -28,9 +28,9 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// Danmaku setting keys, managed through the admin settings UI (PUT
|
||||
// /admin/settings). They are stored in the Setting table so the playback
|
||||
// page can pull them without admin privileges.
|
||||
// Legacy instance-level danmaku settings. Playback preferences now live on
|
||||
// model.User; these keys remain only as a fallback for unauthenticated/legacy
|
||||
// service paths and tests.
|
||||
const (
|
||||
DanmakuEnabledKey = "danmaku.enabled"
|
||||
DanmakuSourceKey = "danmaku.source"
|
||||
@@ -56,21 +56,55 @@ var danmakuOfficialBase = DanmakuDefaultSource
|
||||
|
||||
// DanmakuRenderConfig carries the renderer knobs to the web player.
|
||||
type DanmakuRenderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Opacity string `json:"opacity"`
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
Opacity string `json:"opacity"`
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
Volume float64 `json:"volume"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||
AppKeyConfigured bool `json:"app_key_configured"`
|
||||
}
|
||||
|
||||
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
|
||||
type DanmakuFetchOptions struct {
|
||||
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
|
||||
MergeSources bool
|
||||
// UserID 非空时使用该用户的弹幕源、凭据与渲染偏好。
|
||||
UserID string
|
||||
}
|
||||
|
||||
// DanmakuSettingsPatch 是播放器弹幕设置的局部更新。nil 字段保持不变。
|
||||
type DanmakuSettingsPatch struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Source *string `json:"source"`
|
||||
AppID *string `json:"app_id"`
|
||||
AppKey *string `json:"app_key"`
|
||||
ClearAppKey bool `json:"clear_app_key"`
|
||||
Opacity *float64 `json:"opacity"`
|
||||
FontSize *int `json:"font_size"`
|
||||
Area *float64 `json:"area"`
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
Volume *float64 `json:"volume"`
|
||||
}
|
||||
|
||||
type danmakuUserContextKey struct{}
|
||||
|
||||
type danmakuUserContext struct {
|
||||
source string
|
||||
appID string
|
||||
appKey string
|
||||
}
|
||||
|
||||
// ErrNoDanmakuSettings 表示更新请求没有包含任何可写字段。
|
||||
var ErrNoDanmakuSettings = errors.New("no settings to update")
|
||||
|
||||
// ErrInvalidDanmakuSettings 表示更新请求包含非法值。
|
||||
var ErrInvalidDanmakuSettings = errors.New("invalid player settings")
|
||||
|
||||
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
|
||||
// comment payload; parsing happens client-side. The dandanplay protocol has
|
||||
// two payload shapes in the wild — the classic Bilibili-style XML and the
|
||||
@@ -197,9 +231,11 @@ type danmakuResultCacheEntry struct {
|
||||
storedAt time.Time
|
||||
}
|
||||
|
||||
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
|
||||
func danmakuResultCacheKey(userID, source, credentialFingerprint, mediaID, keyword, episodeID string, merge bool) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(userID),
|
||||
strings.TrimSpace(source),
|
||||
strings.TrimSpace(credentialFingerprint),
|
||||
mediaID,
|
||||
strings.TrimSpace(keyword),
|
||||
strings.TrimSpace(episodeID),
|
||||
@@ -207,6 +243,18 @@ func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge boo
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
// danmakuCredentialFingerprint keeps credential changes in the result cache
|
||||
// key without persisting the AppSecret in plain text.
|
||||
func danmakuCredentialFingerprint(appID, appKey string) string {
|
||||
appID = strings.TrimSpace(appID)
|
||||
appKey = strings.TrimSpace(appKey)
|
||||
if appID == "" && appKey == "" {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum([]byte(appID + "\x00" + appKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
|
||||
if s == nil || key == "" {
|
||||
return nil, false
|
||||
@@ -300,6 +348,7 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
Volume: 1,
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
@@ -319,39 +368,164 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
|
||||
// ConfigForUser 返回当前用户的播放器与弹幕偏好。用户不存在时回退到旧版
|
||||
// 全局配置,便于旧客户端和迁移期平滑工作。
|
||||
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
|
||||
cfg := s.Config(ctx)
|
||||
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
|
||||
return cfg
|
||||
if user, ok := s.findUser(ctx, userID); ok {
|
||||
return danmakuConfigFromUser(user)
|
||||
}
|
||||
return s.Config(ctx)
|
||||
}
|
||||
|
||||
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
|
||||
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return false
|
||||
if user, ok := s.findUser(ctx, userID); ok {
|
||||
return user.DanmakuMergeSources
|
||||
}
|
||||
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
|
||||
return false
|
||||
}
|
||||
|
||||
// SetMergeSources 持久化该用户的弹幕合并偏好。
|
||||
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
|
||||
func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.User, bool) {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return errors.New("danmaku settings unavailable")
|
||||
return nil, false
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return errors.New("missing user")
|
||||
return nil, false
|
||||
}
|
||||
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
return user, err == nil && user != nil
|
||||
}
|
||||
|
||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||
if user == nil {
|
||||
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
|
||||
}
|
||||
opacity := user.DanmakuOpacity
|
||||
if opacity < 0.1 || opacity > 1 {
|
||||
opacity = 1
|
||||
}
|
||||
fontSize := user.DanmakuFontSize
|
||||
if fontSize < 10 || fontSize > 96 {
|
||||
fontSize = 24
|
||||
}
|
||||
area := user.DanmakuArea
|
||||
if area < 0.1 || area > 1 {
|
||||
area = 1
|
||||
}
|
||||
volume := user.PlayerVolume
|
||||
if volume < 0 || volume > 1 {
|
||||
volume = 1
|
||||
}
|
||||
return DanmakuRenderConfig{
|
||||
Enabled: user.DanmakuEnabled,
|
||||
Source: strings.TrimSpace(user.DanmakuSource),
|
||||
AppID: strings.TrimSpace(user.DanmakuAppID),
|
||||
Opacity: strconv.FormatFloat(opacity, 'f', -1, 64),
|
||||
FontSize: strconv.Itoa(fontSize),
|
||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||
Volume: volume,
|
||||
MergeSources: user.DanmakuMergeSources,
|
||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUserSettings 持久化当前用户的播放器与弹幕设置,并返回脱敏后的完整配置。
|
||||
func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, patch DanmakuSettingsPatch) (DanmakuRenderConfig, error) {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return DanmakuRenderConfig{}, errors.New("player settings unavailable")
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return DanmakuRenderConfig{}, errors.New("missing user")
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if patch.Enabled != nil {
|
||||
updates["danmaku_enabled"] = *patch.Enabled
|
||||
}
|
||||
if patch.Source != nil {
|
||||
source, err := normalizeDanmakuSource(*patch.Source)
|
||||
if err != nil {
|
||||
return DanmakuRenderConfig{}, err
|
||||
}
|
||||
updates["danmaku_source"] = source
|
||||
}
|
||||
if patch.AppID != nil {
|
||||
appID := strings.TrimSpace(*patch.AppID)
|
||||
if len(appID) > 128 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_id is too long", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["danmaku_app_id"] = appID
|
||||
}
|
||||
if patch.ClearAppKey {
|
||||
updates["danmaku_app_key"] = ""
|
||||
} else if patch.AppKey != nil {
|
||||
if key := strings.TrimSpace(*patch.AppKey); key != "" {
|
||||
if len(key) > 256 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_key is too long", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["danmaku_app_key"] = key
|
||||
}
|
||||
}
|
||||
if patch.Opacity != nil {
|
||||
if *patch.Opacity < 0.1 || *patch.Opacity > 1 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: opacity must be between 0.1 and 1", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["danmaku_opacity"] = *patch.Opacity
|
||||
}
|
||||
if patch.FontSize != nil {
|
||||
if *patch.FontSize < 10 || *patch.FontSize > 96 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: font_size must be between 10 and 96", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["danmaku_font_size"] = *patch.FontSize
|
||||
}
|
||||
if patch.Area != nil {
|
||||
if *patch.Area < 0.1 || *patch.Area > 1 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: area must be between 0.1 and 1", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["danmaku_area"] = *patch.Area
|
||||
}
|
||||
if patch.MergeSources != nil {
|
||||
updates["danmaku_merge_sources"] = *patch.MergeSources
|
||||
}
|
||||
if patch.Volume != nil {
|
||||
if *patch.Volume < 0 || *patch.Volume > 1 {
|
||||
return DanmakuRenderConfig{}, fmt.Errorf("%w: volume must be between 0 and 1", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
updates["player_volume"] = *patch.Volume
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||
}
|
||||
if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil {
|
||||
return DanmakuRenderConfig{}, err
|
||||
}
|
||||
return s.ConfigForUser(ctx, userID), nil
|
||||
}
|
||||
|
||||
// normalizeDanmakuSource validates a user-configured dandanplay endpoint. An
|
||||
// empty value means the official endpoint; only explicit HTTP(S) origins are
|
||||
// accepted so malformed values never reach the outbound request layer.
|
||||
func normalizeDanmakuSource(raw string) (string, error) {
|
||||
source := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if source == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(source) > 512 {
|
||||
return "", fmt.Errorf("%w: source is too long", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
u, err := url.Parse(source)
|
||||
if err != nil || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.User != nil {
|
||||
return "", fmt.Errorf("%w: source must be an http(s) URL", ErrInvalidDanmakuSettings)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// SetMergeSources 持久化该用户的弹幕合并偏好,保留旧调用点兼容性。
|
||||
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
|
||||
_, err := s.UpdateUserSettings(ctx, userID, DanmakuSettingsPatch{MergeSources: &enabled})
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch retrieves danmaku for the given media. keyword overrides the
|
||||
@@ -388,11 +562,23 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
|
||||
return nil, errors.New("danmaku service unavailable")
|
||||
}
|
||||
cfg := s.Config(ctx)
|
||||
credentialFingerprint := ""
|
||||
if user, ok := s.findUser(ctx, opts.UserID); ok {
|
||||
cfg = danmakuConfigFromUser(user)
|
||||
opts.MergeSources = cfg.MergeSources
|
||||
credentialFingerprint = danmakuCredentialFingerprint(user.DanmakuAppID, user.DanmakuAppKey)
|
||||
ctx = context.WithValue(ctx, danmakuUserContextKey{}, danmakuUserContext{
|
||||
source: cfg.Source,
|
||||
appID: strings.TrimSpace(user.DanmakuAppID),
|
||||
appKey: strings.TrimSpace(user.DanmakuAppKey),
|
||||
})
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
|
||||
}
|
||||
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
|
||||
key := danmakuResultCacheKey(opts.UserID, cfg.Source, credentialFingerprint, mediaID, keyword, episodeID, opts.MergeSources)
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
cached.DanmakuRenderConfig = cfg
|
||||
return cached, nil
|
||||
}
|
||||
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
|
||||
@@ -400,7 +586,7 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
|
||||
res, err := s.fetchWithOptionsUncached(ctx, cfg, mediaID, keyword, episodeID, opts)
|
||||
if err != nil {
|
||||
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
|
||||
return res, err
|
||||
@@ -409,6 +595,9 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
|
||||
return res, nil
|
||||
})
|
||||
res, _ := value.(*DanmakuFetchResult)
|
||||
if res != nil {
|
||||
res.DanmakuRenderConfig = cfg
|
||||
}
|
||||
if err != nil {
|
||||
return cloneDanmakuFetchResult(res), err
|
||||
}
|
||||
@@ -416,8 +605,8 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
|
||||
}
|
||||
|
||||
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
|
||||
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
|
||||
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, cfg DanmakuRenderConfig, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
res := &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}
|
||||
if !res.Enabled {
|
||||
return res, nil
|
||||
}
|
||||
@@ -870,19 +1059,37 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// danmakuCredentials resolves the application credentials for the official
|
||||
// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
|
||||
// win; otherwise the built-in obfuscated fallback pair is used. Returns
|
||||
// ok=false for any other host so credentials — including the built-in pair —
|
||||
// are never sent to third-party dandanplay protocol mirrors. The "official"
|
||||
// host follows danmakuOfficialBase (overridable in tests).
|
||||
// danmakuCredentials resolves application credentials. Per-user explicit
|
||||
// credentials are used for any configured source. Legacy global credentials
|
||||
// and the built-in fallback pair are only sent to the official host, never to
|
||||
// third-party dandanplay protocol mirrors.
|
||||
func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
official, err := url.Parse(danmakuOfficialBase)
|
||||
if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
isOfficial := strings.EqualFold(u.Hostname(), official.Hostname())
|
||||
if userCtx, ok := ctx.Value(danmakuUserContextKey{}).(danmakuUserContext); ok {
|
||||
userSource := strings.TrimSpace(userCtx.source)
|
||||
if userSource == "" {
|
||||
userSource = danmakuOfficialBase
|
||||
}
|
||||
if sameDanmakuBase(sourceURL, userSource) {
|
||||
id, key := strings.TrimSpace(userCtx.appID), strings.TrimSpace(userCtx.appKey)
|
||||
if id != "" && key != "" {
|
||||
return id, key, true
|
||||
}
|
||||
if id != "" || key != "" {
|
||||
s.log.Warn("danmaku user credentials incomplete, ignoring them",
|
||||
zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isOfficial {
|
||||
return "", "", false
|
||||
}
|
||||
var id, key string
|
||||
|
||||
@@ -60,6 +60,7 @@ func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName
|
||||
type danmakuSourceServer struct {
|
||||
server *httptest.Server
|
||||
lastSearch string // full query (anime=...&episode=...)
|
||||
lastHeaders http.Header
|
||||
searchResponse string // JSON body served for /api/v2/search/episodes
|
||||
}
|
||||
|
||||
@@ -76,6 +77,7 @@ func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danm
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
ds.lastSearch = r.URL.RawQuery
|
||||
ds.lastHeaders = r.Header.Clone()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, ds.searchResponse)
|
||||
})
|
||||
@@ -404,3 +406,41 @@ func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
|
||||
}
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
|
||||
}
|
||||
|
||||
func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := model.User{Username: "danmaku-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "danmaku-user-1"
|
||||
user.DanmakuEnabled = true
|
||||
user.DanmakuSource = srv.URL()
|
||||
user.DanmakuAppID = "user-app-id"
|
||||
user.DanmakuAppKey = "user-app-secret"
|
||||
user.DanmakuOpacity = 0.65
|
||||
user.DanmakuFontSize = 30
|
||||
user.DanmakuArea = 0.7
|
||||
user.PlayerVolume = 0.42
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, srv.URL(), res.Source)
|
||||
require.Equal(t, 0.42, res.Volume)
|
||||
require.Equal(t, "0.65", res.Opacity)
|
||||
require.Equal(t, "30", res.FontSize)
|
||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||
require.NotEmpty(t, srv.lastHeaders.Get("X-Signature"))
|
||||
|
||||
// Credential rotation must invalidate the cached fetch for the same user.
|
||||
require.NoError(t, svc.repo.User.UpdateFields(ctx, user.ID, map[string]any{
|
||||
"danmaku_app_id": "user-app-id-2",
|
||||
"danmaku_app_key": "user-app-secret-2",
|
||||
}))
|
||||
_, err = svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-app-id-2", srv.lastHeaders.Get("X-AppId"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user