mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f1cd85f8 | |||
| be9d3a6d9e | |||
| b8e48a1f45 |
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
||||
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
|
||||
@@ -85,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if c.Query("ids") == "1" {
|
||||
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ids": ids})
|
||||
return
|
||||
}
|
||||
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
+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"))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
||||
@@ -65,6 +66,11 @@ type EmbyService struct {
|
||||
peopleMu sync.RWMutex
|
||||
peopleCache map[string]embyPeopleCacheEntry
|
||||
|
||||
// latestFlight collapses the homepage stampede: clients request Latest
|
||||
// for every library at once, and a shared expiry used to rebuild each
|
||||
// library in parallel.
|
||||
latestFlight singleflight.Group
|
||||
|
||||
tmdb *TMDbProvider
|
||||
adult *AdultProvider
|
||||
personImageMu sync.RWMutex
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -120,44 +121,87 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
|
||||
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
|
||||
return items, nil
|
||||
}
|
||||
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
|
||||
loadCtx := context.WithoutCancel(ctx)
|
||||
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
|
||||
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
|
||||
return embyLatestCacheValue{Items: items}, nil
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
e.rememberArtworkRefs(value.Artwork)
|
||||
return value, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached, _ := v.(embyLatestCacheValue)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return cached.Items, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
|
||||
if e == nil || e.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return cached.Items, nil
|
||||
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return nil, false
|
||||
}
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
if cached.Items == nil {
|
||||
return []map[string]any{}, true
|
||||
}
|
||||
return cached.Items, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return embyLatestCacheValue{Items: []map[string]any{}}, nil
|
||||
}
|
||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
if err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
return embyLatestCacheValue{Items: out}, nil
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if parentID != "" {
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err == nil && e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
if err != nil {
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
return out, err
|
||||
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
|
||||
}
|
||||
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
|
||||
}
|
||||
@@ -170,7 +214,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
if len(rows) > limit {
|
||||
@@ -178,12 +222,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
}
|
||||
out, err := e.payloadsForMedia(ctx, rows, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return embyLatestCacheValue{}, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
return embyLatestCacheValue{Items: out}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
|
||||
@@ -194,7 +235,8 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
@@ -209,6 +251,24 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
return items, e.artworkRefsForSeriesGroups(groups), nil
|
||||
}
|
||||
|
||||
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
|
||||
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
|
||||
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
|
||||
// 仍走完整分组。上限 2000 行。
|
||||
func embyLatestSeriesRowLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
n := limit * 40
|
||||
if n < 200 {
|
||||
n = 200
|
||||
}
|
||||
if n > 2000 {
|
||||
n = 2000
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
@@ -393,7 +453,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
|
||||
// 足够准确。
|
||||
needed := p.StartIndex + p.Limit
|
||||
items := make([]map[string]any, 0, p.Limit)
|
||||
slots := make([]resumeSlot, 0, len(hist))
|
||||
localTotal, remoteTotal := 0, 0
|
||||
for _, h := range hist {
|
||||
if m, ok := byID[h.MediaID]; ok {
|
||||
@@ -401,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
if produced := len(items); produced < needed {
|
||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
|
||||
}
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||
continue
|
||||
}
|
||||
remoteTotal++
|
||||
if len(items) >= needed {
|
||||
continue
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
continue
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
continue
|
||||
}
|
||||
if p.ParentID != "" {
|
||||
parentID, _ := item["ParentId"].(string)
|
||||
seriesID, _ := item["SeriesId"].(string)
|
||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
|
||||
}
|
||||
for {
|
||||
filled := 0
|
||||
pending := make([]*resumeRemoteFetch, 0)
|
||||
for i := range slots {
|
||||
if slots[i].item != nil {
|
||||
filled++
|
||||
if filled >= needed {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
f := slots[i].fetch
|
||||
if f == nil || f.done {
|
||||
continue
|
||||
}
|
||||
if f.mount == nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
f.done = true
|
||||
continue
|
||||
}
|
||||
f.mount, f.acct, f.remoteID = mount, acct, remoteID
|
||||
}
|
||||
pending = append(pending, f)
|
||||
if filled+len(pending) >= needed {
|
||||
break
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
||||
items = append(items, item)
|
||||
if len(pending) == 0 {
|
||||
break
|
||||
}
|
||||
e.fillResumeRemotes(ctx, pending)
|
||||
for i := range slots {
|
||||
f := slots[i].fetch
|
||||
if f == nil || !pendingContains(pending, f) {
|
||||
continue
|
||||
}
|
||||
f.done = true
|
||||
item := f.item
|
||||
f.item = nil
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if p.ParentID != "" {
|
||||
parentID, _ := item["ParentId"].(string)
|
||||
seriesID, _ := item["SeriesId"].(string)
|
||||
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
|
||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
||||
slots[i].item = item
|
||||
}
|
||||
}
|
||||
items := make([]map[string]any, 0, p.Limit)
|
||||
for i := range slots {
|
||||
if slots[i].item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, slots[i].item)
|
||||
}
|
||||
|
||||
total := int64(localTotal + remoteTotal)
|
||||
@@ -441,6 +542,57 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
type resumeSlot struct {
|
||||
item map[string]any
|
||||
fetch *resumeRemoteFetch
|
||||
}
|
||||
|
||||
type resumeRemoteFetch struct {
|
||||
hist model.PlaybackHistory
|
||||
mount *model.EmbyMount
|
||||
acct *model.StrmAccount
|
||||
remoteID string
|
||||
item map[string]any
|
||||
done bool
|
||||
}
|
||||
|
||||
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
|
||||
for _, row := range rows {
|
||||
if row == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
|
||||
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
|
||||
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
|
||||
if e == nil || e.remote == nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 4)
|
||||
for i := range rows {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
|
||||
if err != nil || item == nil {
|
||||
return
|
||||
}
|
||||
rows[i].item = item
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
|
||||
itemType := "Movie"
|
||||
name := m.Title
|
||||
|
||||
@@ -805,6 +805,15 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
|
||||
|
||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
cacheKey := ""
|
||||
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
|
||||
cacheKey = r.remoteCacheKey("item", mount.ID, remoteID)
|
||||
var cached map[string]any
|
||||
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
|
||||
r.rememberRemotePeople(mount, cached)
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -817,6 +826,9 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
||||
}
|
||||
r.rememberRemotePeople(mount, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
if cacheKey != "" && len(out) > 0 {
|
||||
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
|
||||
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
|
||||
// 供调用方免二次请求读取 Type / SeriesId 等字段。
|
||||
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
|
||||
cacheKey := ""
|
||||
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
|
||||
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
|
||||
var cached struct {
|
||||
Media *model.Media `json:"media"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
|
||||
return cached.Media, cached.Raw, nil
|
||||
}
|
||||
}
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
|
||||
if cacheKey != "" {
|
||||
r.cache.SetJSON(ctx, cacheKey, struct {
|
||||
Media *model.Media `json:"media"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return &m, out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) {
|
||||
if got := embyLatestSeriesRowLimit(25); got < 25*40 {
|
||||
t.Fatalf("latest window %d is smaller than the 25x40 fixture", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
|
||||
|
||||
@@ -226,6 +226,34 @@ func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireResizeSlot 不等待。槽位忙时返回 false,调用方应改出原图。
|
||||
func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
|
||||
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
|
||||
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
|
||||
if size > 0 && size <= resizeQueueBypassBytes {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
return p.acquireResizeSlot(ctx)
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||
@@ -241,8 +269,13 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
// 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的
|
||||
// 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。
|
||||
if serveCompactOriginal(w, r, srcPath, stat) {
|
||||
return true
|
||||
}
|
||||
|
||||
release, ok := p.acquireResizeSlot(r.Context())
|
||||
release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@@ -273,6 +306,43 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
||||
return true
|
||||
}
|
||||
|
||||
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
|
||||
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
|
||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||
const compactImageSkipBytes = 200 * 1024
|
||||
|
||||
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||
// 以免把未压缩的剧照直接发给客户端。
|
||||
const resizeQueueBypassBytes = 1536 * 1024
|
||||
|
||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||
if stat == nil || stat.Size() <= 0 || stat.Size() > compactImageSkipBytes {
|
||||
return false
|
||||
}
|
||||
file, err := os.Open(srcPath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var header [12]byte
|
||||
n, _ := file.Read(header[:])
|
||||
_ = file.Close()
|
||||
if !isCompactWebImage(header[:n]) {
|
||||
return false
|
||||
}
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
func isCompactWebImage(header []byte) bool {
|
||||
if len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff {
|
||||
return true
|
||||
}
|
||||
return len(header) >= 12 &&
|
||||
string(header[0:4]) == "RIFF" &&
|
||||
string(header[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||
dir := filepath.Dir(cachePath)
|
||||
|
||||
@@ -2,12 +2,15 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
||||
@@ -274,3 +277,60 @@ func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
|
||||
t.Fatal("expected cached thumbnail to be reused without decoding the source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "poster.jpg"
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 800, 1200)), &jpeg.Options{Quality: 40}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 || buf.Len() > compactImageSkipBytes {
|
||||
t.Fatalf("test jpeg is %d bytes, want a compact poster", buf.Len())
|
||||
}
|
||||
if err := os.WriteFile(src, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=120", nil), src, imageResizeOptions{MaxWidth: 120}) {
|
||||
t.Fatal("expected compact jpeg to be served")
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), buf.Bytes()) {
|
||||
t.Fatal("expected the original jpeg, not a decoded thumbnail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "backdrop.jpg"
|
||||
data := make([]byte, compactImageSkipBytes+1024)
|
||||
data[0], data[1], data[2] = 0xff, 0xd8, 0xff
|
||||
if err := os.WriteFile(src, data, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("expected a resize slot")
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
defer func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Fatal("resize slot was busy but the request still waited in the queue")
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,23 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
|
||||
return "media:series-cards:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter repository.MediaQueryFilter) string {
|
||||
ids := append([]string(nil), libraryIDs...)
|
||||
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
|
||||
sort.Strings(ids)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
sum := sha1.Sum([]byte(strings.Join([]string{
|
||||
"counts",
|
||||
strings.Join(ids, ","),
|
||||
fmt.Sprintf("%t", filter.IncludeNSFW),
|
||||
strings.Join(allowed, ","),
|
||||
strings.Join(hidden, ","),
|
||||
}, "|")))
|
||||
return "media:lib-counts:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *MediaService) mediaCacheTTLSeconds() int {
|
||||
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
|
||||
return 90
|
||||
@@ -82,6 +99,18 @@ func (s *MediaService) mediaObjectTTL() time.Duration {
|
||||
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
|
||||
}
|
||||
|
||||
// derivedReadCacheTTL 给首页预览、剧集分组、库计数这类派生读缓存一个更长的
|
||||
// 下限。扫描/刮削/删改都会走 invalidateMediaCache 清掉 media: 前缀,所以
|
||||
// 拉长 TTL 不会在写入后继续吐旧卡片。配置里的 TTL 更长时仍以配置为准。
|
||||
func (s *MediaService) derivedReadCacheTTL() time.Duration {
|
||||
ttl := s.mediaObjectTTL()
|
||||
const floor = 10 * time.Minute
|
||||
if ttl < floor {
|
||||
return floor
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func hashObjectCacheKey(parts []string) string {
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
@@ -24,6 +23,31 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro
|
||||
return s.repo.Library.List(ctx)
|
||||
}
|
||||
|
||||
// CountLibrariesCached returns per-library media totals. The homepage metadata
|
||||
// request asks for every library, and the underlying COUNT is repeated on each
|
||||
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
|
||||
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
|
||||
if len(libraryIDs) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
|
||||
var cached map[string]int64
|
||||
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if counts == nil {
|
||||
counts = map[string]int64{}
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
||||
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
|
||||
@@ -120,7 +144,7 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
|
||||
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
|
||||
@@ -70,7 +70,7 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
|
||||
cards := groupMediaSeriesCardsByKeys(rows, keys)
|
||||
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
|
||||
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
|
||||
}
|
||||
total := int64(len(cards))
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
|
||||
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
|
||||
}
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
@@ -265,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListFavouriteIDs returns the user's favourite media IDs without hydrating
|
||||
// remote Emby items. Heart-icon state only needs the IDs; fetching each remote
|
||||
// detail on every library open adds a few hundred milliseconds.
|
||||
func (p *PlaybackService) ListFavouriteIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if fav.MediaID != "" {
|
||||
ids = append(ids, fav.MediaID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ─── Playlists ──────────────────────────────────────────────────────────────
|
||||
|
||||
// CreatePlaylist persists a new playlist owned by userID.
|
||||
|
||||
+32
-20
@@ -62,6 +62,33 @@ export type DanmakuFetchOptions = {
|
||||
episodeId?: number | string
|
||||
}
|
||||
|
||||
export interface DanmakuConfig {
|
||||
enabled: boolean
|
||||
source?: string
|
||||
app_id?: string
|
||||
app_key_configured: boolean
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
volume: number
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}
|
||||
|
||||
export interface DanmakuSettingsPatch {
|
||||
enabled?: boolean
|
||||
source?: string
|
||||
app_id?: string
|
||||
/** 留空不会修改密钥;清空请使用 clear_app_key。 */
|
||||
app_key?: string
|
||||
clear_app_key?: boolean
|
||||
opacity?: number
|
||||
font_size?: number
|
||||
area?: number
|
||||
merge_sources?: boolean
|
||||
volume?: number
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
// the configured dandanplay source by the video's name (search for episode,
|
||||
// then fetch its comment library) and returns the raw Bilibili-format XML;
|
||||
@@ -75,26 +102,11 @@ export const danmakuAPI = {
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// config returns the renderer knobs so the player can initialize its
|
||||
// danmaku control panel without admin privileges.
|
||||
// config returns the current user's player volume and danmaku preferences.
|
||||
config: () =>
|
||||
api
|
||||
.get<{
|
||||
enabled: boolean
|
||||
source?: string
|
||||
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),
|
||||
api.get<DanmakuConfig>('/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),
|
||||
// updateSettings persists a partial per-user player/danmaku settings patch.
|
||||
updateSettings: (settings: DanmakuSettingsPatch) =>
|
||||
api.put<DanmakuConfig>('/danmaku/settings', settings).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ export const playbackAPI = {
|
||||
listFavourites: () =>
|
||||
api.get<{ items: Media[] }>('/favourites').then((r) => r.data.items),
|
||||
|
||||
listFavouriteIDs: () =>
|
||||
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
|
||||
|
||||
listPlaylists: () =>
|
||||
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { convertASSContent } from '../utils/subtitleASS'
|
||||
import { runWithoutGlobalVideoFrame } from '../utils/withoutVideoFrame'
|
||||
|
||||
type AssSubtitleStageProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
@@ -60,18 +61,21 @@ export function AssSubtitleStage({
|
||||
if (!body.trim()) throw new Error('empty subtitle payload')
|
||||
if (cancelled) return
|
||||
|
||||
instance = new JASSUB({
|
||||
video,
|
||||
subContent: convertASSContent(body, converter),
|
||||
timeOffset: timeOffsetRef.current,
|
||||
workerUrl: jassubWorkerUrl,
|
||||
wasmUrl: jassubWasmUrl,
|
||||
modernWasmUrl: jassubModernWasmUrl,
|
||||
fonts: [notoSansSCUrl],
|
||||
defaultFont: 'Noto Sans SC',
|
||||
queryFonts: 'local',
|
||||
prescaleFactor: 1,
|
||||
})
|
||||
instance = runWithoutGlobalVideoFrame(
|
||||
() =>
|
||||
new JASSUB({
|
||||
video,
|
||||
subContent: convertASSContent(body, converter),
|
||||
timeOffset: timeOffsetRef.current,
|
||||
workerUrl: jassubWorkerUrl,
|
||||
wasmUrl: jassubWasmUrl,
|
||||
modernWasmUrl: jassubModernWasmUrl,
|
||||
fonts: [notoSansSCUrl],
|
||||
defaultFont: 'Noto Sans SC',
|
||||
queryFonts: 'local',
|
||||
prescaleFactor: 1,
|
||||
}),
|
||||
)
|
||||
instanceRef.current = instance
|
||||
await instance.ready
|
||||
if (cancelled) {
|
||||
|
||||
@@ -264,7 +264,7 @@ export function DanmakuStage({
|
||||
managerRef.current = null
|
||||
}
|
||||
// search / episodeId / searchTrigger 变化时重新拉取弹幕(含媒体/开关切换)。
|
||||
}, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates])
|
||||
}, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates, onAlternatives])
|
||||
|
||||
// Live renderer knobs: opacity / area / font size without recreating the
|
||||
// engine. font size additionally rescales currently visible comments.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
FastForward,
|
||||
ListVideo,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
Rewind,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
@@ -35,8 +38,30 @@ function formatTime(s: number): string {
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const SEEK_STEP_SEC = 10
|
||||
const SEEK_REPEAT_MS = 160
|
||||
const SEEK_APPLY_MS = 220
|
||||
const SEEK_HINT_MS = 700
|
||||
|
||||
type SeekHint = {
|
||||
dir: 'back' | 'forward'
|
||||
seconds: number
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return Boolean(
|
||||
target.closest('input, textarea, select, [contenteditable="true"]') ||
|
||||
target.isContentEditable,
|
||||
)
|
||||
}
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
/** 用户级播放器音量(0 ~ 1),由播放页从数据库读取。 */
|
||||
volume?: number
|
||||
onVolumeChange?: (volume: number) => void
|
||||
onVolumeCommit?: (volume: number) => void
|
||||
uiVisible: boolean
|
||||
onUiVisibleChange: (visible: boolean) => void
|
||||
subs: SubtitleTrack[]
|
||||
@@ -71,6 +96,9 @@ type PlayerControlsProps = {
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
volume: volumeProp = 1,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
uiVisible,
|
||||
onUiVisibleChange,
|
||||
subs,
|
||||
@@ -107,8 +135,8 @@ export function PlayerControls({
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [volume, setVolume] = useState(volumeProp)
|
||||
const [muted, setMuted] = useState(volumeProp === 0)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [controlsHovered, setControlsHovered] = useState(false)
|
||||
@@ -123,6 +151,14 @@ export function PlayerControls({
|
||||
const danmakuOpenRef = useRef(false)
|
||||
const playlistOpenRef = useRef(false)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
||||
const durationRef = useRef(0)
|
||||
const seekBurstRef = useRef<{ base: number; delta: number } | null>(null)
|
||||
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastSeekAtRef = useRef(0)
|
||||
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
|
||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
controlsHoveredRef.current = controlsHovered
|
||||
@@ -144,6 +180,25 @@ export function PlayerControls({
|
||||
playlistOpenRef.current = playlistOpen
|
||||
}, [playlistOpen])
|
||||
|
||||
useEffect(() => {
|
||||
durationRef.current = duration
|
||||
}, [duration])
|
||||
|
||||
useEffect(() => {
|
||||
setStageEl(container())
|
||||
}, [container])
|
||||
|
||||
// 音量由播放页按用户持久化;配置加载或切换对象后同步到当前 video。
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
const next = Math.min(1, Math.max(0, volumeProp))
|
||||
el.volume = next
|
||||
el.muted = next === 0
|
||||
setVolume(next)
|
||||
setMuted(next === 0)
|
||||
}, [video, volumeProp])
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
@@ -299,18 +354,25 @@ export function PlayerControls({
|
||||
else el.pause()
|
||||
}
|
||||
|
||||
const applyAbsoluteSeek = (absolute: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (onSeekAbsolute?.(absolute)) {
|
||||
pendingSeekRef.current = absolute
|
||||
setCurrentTime(absolute)
|
||||
return
|
||||
}
|
||||
const local = Math.max(0, absolute - streamOffset)
|
||||
el.currentTime = local
|
||||
setCurrentTime(streamOffset + local)
|
||||
}
|
||||
const applyAbsoluteSeek = useCallback(
|
||||
(absolute: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (onSeekAbsolute?.(absolute)) {
|
||||
pendingSeekRef.current = absolute
|
||||
setCurrentTime(absolute)
|
||||
return
|
||||
}
|
||||
const local = Math.max(0, absolute - streamOffset)
|
||||
el.currentTime = local
|
||||
setCurrentTime(streamOffset + local)
|
||||
},
|
||||
[onSeekAbsolute, streamOffset, video],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
applySeekRef.current = applyAbsoluteSeek
|
||||
}, [applyAbsoluteSeek])
|
||||
|
||||
const handleSeekChange = (v: number) => {
|
||||
setScrubValue(v)
|
||||
@@ -321,6 +383,11 @@ export function PlayerControls({
|
||||
}
|
||||
|
||||
const handleSeekStart = () => {
|
||||
if (seekApplyTimerRef.current) {
|
||||
clearTimeout(seekApplyTimerRef.current)
|
||||
seekApplyTimerRef.current = null
|
||||
}
|
||||
seekBurstRef.current = null
|
||||
setIsScrubbing(true)
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
@@ -332,13 +399,116 @@ export function PlayerControls({
|
||||
setScrubValue(null)
|
||||
}
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
onUiVisibleChange(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
const el = video()
|
||||
if (
|
||||
!el ||
|
||||
el.paused ||
|
||||
controlsHoveredRef.current ||
|
||||
isScrubbingRef.current ||
|
||||
subtitleMenuOpenRef.current ||
|
||||
danmakuOpenRef.current ||
|
||||
playlistOpenRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (
|
||||
!controlsHoveredRef.current &&
|
||||
!isScrubbingRef.current &&
|
||||
!subtitleMenuOpenRef.current &&
|
||||
!danmakuOpenRef.current &&
|
||||
!playlistOpenRef.current
|
||||
) {
|
||||
onUiVisibleChange(false)
|
||||
}
|
||||
}, 3000)
|
||||
}, [onUiVisibleChange, video])
|
||||
|
||||
const queueRelativeSeek = useCallback((delta: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (!seekBurstRef.current) {
|
||||
const base = pendingSeekRef.current ?? streamOffset + (el.currentTime || 0)
|
||||
seekBurstRef.current = { base, delta: 0 }
|
||||
}
|
||||
seekBurstRef.current.delta += delta
|
||||
const max = durationRef.current > 0 ? durationRef.current : Number.POSITIVE_INFINITY
|
||||
const target = Math.min(max, Math.max(0, seekBurstRef.current.base + seekBurstRef.current.delta))
|
||||
const applied = target - seekBurstRef.current.base
|
||||
seekBurstRef.current.delta = applied
|
||||
if (applied === 0) {
|
||||
if (!seekApplyTimerRef.current) {
|
||||
seekBurstRef.current = null
|
||||
setSeekHint({
|
||||
dir: delta < 0 ? 'back' : 'forward',
|
||||
seconds: 0,
|
||||
})
|
||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingSeekRef.current = target
|
||||
setCurrentTime(target)
|
||||
setSeekHint({
|
||||
dir: applied < 0 ? 'back' : 'forward',
|
||||
seconds: Math.abs(Math.round(applied)),
|
||||
})
|
||||
revealControls()
|
||||
|
||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
seekApplyTimerRef.current = setTimeout(() => {
|
||||
const burst = seekBurstRef.current
|
||||
seekBurstRef.current = null
|
||||
seekApplyTimerRef.current = null
|
||||
if (!burst) return
|
||||
applySeekRef.current(burst.base + burst.delta)
|
||||
}, SEEK_APPLY_MS)
|
||||
}, [revealControls, streamOffset, video])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (isEditableTarget(e.target)) return
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const now = Date.now()
|
||||
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
|
||||
lastSeekAtRef.current = now
|
||||
e.preventDefault()
|
||||
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [queueRelativeSeek])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeVolume = (v: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.volume = v
|
||||
el.muted = v === 0
|
||||
setVolume(v)
|
||||
setMuted(v === 0)
|
||||
const next = Math.min(1, Math.max(0, v))
|
||||
el.volume = next
|
||||
el.muted = next === 0
|
||||
setVolume(next)
|
||||
setMuted(next === 0)
|
||||
onVolumeChange?.(next)
|
||||
}
|
||||
|
||||
const commitVolume = () => {
|
||||
const el = video()
|
||||
onVolumeCommit?.(el?.volume ?? volume)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
@@ -380,7 +550,39 @@ export function PlayerControls({
|
||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||
|
||||
const seekOverlay = seekHint && stageEl
|
||||
? createPortal(
|
||||
<div className="pointer-events-none absolute inset-0 z-30">
|
||||
<div
|
||||
className={`absolute top-1/2 flex w-36 -translate-y-1/2 flex-col items-center justify-center rounded-full bg-black/55 px-3 py-5 text-white shadow-lg backdrop-blur-sm ${
|
||||
seekHint.dir === 'back' ? 'left-[8%] sm:left-[12%]' : 'right-[8%] sm:right-[12%]'
|
||||
}`}
|
||||
>
|
||||
{seekHint.dir === 'back' ? <Rewind size={28} /> : <FastForward size={28} />}
|
||||
<span className="mt-1 text-center text-sm font-medium leading-tight">
|
||||
{seekHint.seconds > 0
|
||||
? seekHint.dir === 'back'
|
||||
? '回退'
|
||||
: '快进'
|
||||
: seekHint.dir === 'back'
|
||||
? '已到开头'
|
||||
: '已到结尾'}
|
||||
{seekHint.seconds > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="tabular-nums">{seekHint.seconds} 秒</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
stageEl,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{seekOverlay}
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
@@ -632,6 +834,9 @@ export function PlayerControls({
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
onPointerUp={commitVolume}
|
||||
onKeyUp={commitVolume}
|
||||
onTouchEnd={commitVolume}
|
||||
className="hidden w-16 accent-rose-500 sm:block"
|
||||
aria-label="音量"
|
||||
/>
|
||||
@@ -645,5 +850,6 @@ export function PlayerControls({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronRight, Film, Hash, Loader2, MessageSquareText, RefreshCw, Search, Sparkles, Tag, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||
|
||||
@@ -17,10 +17,24 @@ type PlayerDanmakuPanelProps = {
|
||||
searching: boolean
|
||||
area: number
|
||||
onAreaChange: (v: number) => void
|
||||
onAreaCommit: (v: number) => void
|
||||
opacity: number
|
||||
onOpacityChange: (v: number) => void
|
||||
onOpacityCommit: (v: number) => void
|
||||
fontSize: number
|
||||
onFontSizeChange: (v: number) => void
|
||||
onFontSizeCommit: (v: number) => void
|
||||
/** Per-user danmaku service endpoint and optional application credentials. */
|
||||
source: string
|
||||
appId: string
|
||||
appKeyConfigured: boolean
|
||||
settingsSaving?: boolean
|
||||
onSaveAdvanced: (values: {
|
||||
source: string
|
||||
appId: string
|
||||
appKey: string
|
||||
clearAppKey: boolean
|
||||
}) => Promise<void>
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/**
|
||||
@@ -53,10 +67,18 @@ export function PlayerDanmakuPanel({
|
||||
searching,
|
||||
area,
|
||||
onAreaChange,
|
||||
onAreaCommit,
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
onOpacityCommit,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
onFontSizeCommit,
|
||||
source,
|
||||
appId,
|
||||
appKeyConfigured,
|
||||
settingsSaving = false,
|
||||
onSaveAdvanced,
|
||||
candidates,
|
||||
alternatives,
|
||||
mergeSources,
|
||||
@@ -69,6 +91,13 @@ export function PlayerDanmakuPanel({
|
||||
onResetAuto,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
const [draft, setDraft] = useState(search)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [sourceDraft, setSourceDraft] = useState(source)
|
||||
const [appIdDraft, setAppIdDraft] = useState(appId)
|
||||
const [appKeyDraft, setAppKeyDraft] = useState('')
|
||||
const [showAppKey, setShowAppKey] = useState(false)
|
||||
const [clearAppKey, setClearAppKey] = useState(false)
|
||||
const [advancedSaving, setAdvancedSaving] = useState(false)
|
||||
// 展开的番剧(动画 → 集数两级树),默认全展开便于选择。
|
||||
const [openAnime, setOpenAnime] = useState<Set<number>>(new Set())
|
||||
|
||||
@@ -77,8 +106,32 @@ export function PlayerDanmakuPanel({
|
||||
if (open) {
|
||||
setDraft(search)
|
||||
setOpenAnime(new Set(candidates.map((c) => c.animeId)))
|
||||
setSourceDraft(source)
|
||||
setAppIdDraft(appId)
|
||||
setAppKeyDraft('')
|
||||
setShowAppKey(false)
|
||||
setClearAppKey(false)
|
||||
}
|
||||
}, [open, search, candidates])
|
||||
}, [open, search, candidates, source, appId])
|
||||
|
||||
const saveAdvanced = async () => {
|
||||
setAdvancedSaving(true)
|
||||
try {
|
||||
await onSaveAdvanced({
|
||||
source: sourceDraft,
|
||||
appId: appIdDraft,
|
||||
appKey: appKeyDraft,
|
||||
clearAppKey,
|
||||
})
|
||||
setAppKeyDraft('')
|
||||
setShowAppKey(false)
|
||||
setClearAppKey(false)
|
||||
} catch {
|
||||
// 父组件已展示错误提示,这里保持面板打开以便修正后重试。
|
||||
} finally {
|
||||
setAdvancedSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -148,6 +201,7 @@ export function PlayerDanmakuPanel({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
|
||||
{settingsSaving && <Loader2 size={12} className="animate-spin text-rose-300" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -397,6 +451,7 @@ export function PlayerDanmakuPanel({
|
||||
step={0.05}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
onChange={onAreaChange}
|
||||
onCommit={onAreaCommit}
|
||||
/>
|
||||
{/* 透明度 */}
|
||||
<SliderRow
|
||||
@@ -407,6 +462,7 @@ export function PlayerDanmakuPanel({
|
||||
step={0.05}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
onChange={onOpacityChange}
|
||||
onCommit={onOpacityCommit}
|
||||
/>
|
||||
{/* 字体大小 */}
|
||||
<SliderRow
|
||||
@@ -417,7 +473,106 @@ export function PlayerDanmakuPanel({
|
||||
step={1}
|
||||
format={(v) => `${Math.round(v)}px`}
|
||||
onChange={onFontSizeChange}
|
||||
onCommit={onFontSizeCommit}
|
||||
/>
|
||||
<div className="mt-3 border-t border-white/10 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-xs font-medium text-white/75 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Settings2 size={13} className="text-rose-300" /> 高级
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={'transition-transform ' + (advancedOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="mt-2 space-y-2.5 rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1 text-[11px] text-white/55">
|
||||
<Server size={11} /> 弹幕服务地址
|
||||
</span>
|
||||
<input
|
||||
value={sourceDraft}
|
||||
onChange={(e) => setSourceDraft(e.target.value)}
|
||||
placeholder="留空使用 https://api.dandanplay.net"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/5 px-2.5 py-2 text-xs outline-none placeholder:text-white/30 focus:border-rose-400/60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1 text-[11px] text-white/55">
|
||||
<KeyRound size={11} /> 开放 API AppId
|
||||
</span>
|
||||
<input
|
||||
value={appIdDraft}
|
||||
onChange={(e) => setAppIdDraft(e.target.value)}
|
||||
placeholder="官方源可留空;第三方源按需填写"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/5 px-2.5 py-2 text-xs outline-none placeholder:text-white/30 focus:border-rose-400/60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center justify-between gap-2 text-[11px] text-white/55">
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyRound size={11} /> 应用密钥 AppSecret
|
||||
</span>
|
||||
{appKeyConfigured && !clearAppKey && (
|
||||
<span className="text-[10px] text-emerald-300">已保存</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="relative block">
|
||||
<input
|
||||
type={showAppKey ? 'text' : 'password'}
|
||||
value={appKeyDraft}
|
||||
disabled={clearAppKey}
|
||||
onChange={(e) => setAppKeyDraft(e.target.value)}
|
||||
placeholder={appKeyConfigured ? '留空保持不变' : '仅保存在服务端'}
|
||||
className="w-full rounded-lg border border-white/15 bg-white/5 px-2.5 py-2 pr-8 text-xs outline-none placeholder:text-white/30 focus:border-rose-400/60 disabled:opacity-40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppKey((value) => !value)}
|
||||
disabled={clearAppKey}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-white/45 hover:text-white disabled:opacity-30"
|
||||
title={showAppKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{showAppKey ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{appKeyConfigured && (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-[10px] text-amber-200/75">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearAppKey}
|
||||
onChange={(e) => setClearAppKey(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-amber-500"
|
||||
/>
|
||||
清除已保存的应用密钥
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveAdvanced()}
|
||||
disabled={advancedSaving || settingsSaving}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-rose-500 px-2.5 py-2 text-xs font-medium text-white transition hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
{(advancedSaving || settingsSaving) && <Loader2 size={12} className="animate-spin" />}
|
||||
保存服务设置
|
||||
</button>
|
||||
<p className="text-[10px] leading-relaxed text-white/35">
|
||||
密钥不会回传到浏览器,留空时保持数据库中的原值;官方源留空凭据会使用内置回退,自定义源需自行提供。修改后仅对当前账号生效。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -430,6 +585,7 @@ function SliderRow({
|
||||
step,
|
||||
format,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
@@ -438,6 +594,7 @@ function SliderRow({
|
||||
step: number
|
||||
format: (v: number) => string
|
||||
onChange: (v: number) => void
|
||||
onCommit: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
@@ -452,6 +609,8 @@ function SliderRow({
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerUp={(e) => onCommit(Number(e.currentTarget.value))}
|
||||
onKeyUp={(e) => onCommit(Number(e.currentTarget.value))}
|
||||
className="w-full accent-rose-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,10 @@ export function useFavourites() {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.listFavouriteIDs()
|
||||
.then((items) => {
|
||||
if (!cancelled) {
|
||||
setFavouriteIDs(new Set((items ?? []).map((item) => item.id)))
|
||||
setFavouriteIDs(new Set(items ?? []))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
ContinueWatchingSkeleton,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
|
||||
const PREVIEW_BATCH_SIZE = 4
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
|
||||
export function HomePage() {
|
||||
@@ -102,12 +102,10 @@ export function HomePage() {
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
|
||||
|
||||
const batches: string[][] = []
|
||||
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
|
||||
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
|
||||
}
|
||||
// 本地库一次拉完(同一条窗口查询、缓存键稳定)。远程库单独一批,
|
||||
// 避免一个慢的 Emby 挂载拖住整页预览。
|
||||
const batches = partitionPreviewIDs(targets)
|
||||
|
||||
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
|
||||
await Promise.allSettled(
|
||||
batches.map(async (batch) => {
|
||||
let loaded = false
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -33,30 +34,33 @@ export function LibrariesPage() {
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
let loaded = false
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
loaded = true
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
const batches = partitionPreviewIDs(targets)
|
||||
await Promise.allSettled(
|
||||
batches.map(async (batch) => {
|
||||
let loaded = false
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(batch, 10)
|
||||
loaded = true
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 单个批次失败不影响其他批次。
|
||||
} finally {
|
||||
batch.forEach((id) => {
|
||||
if (loaded) fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
targets.forEach((id) => {
|
||||
if (loaded) {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
}
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
||||
|
||||
+162
-30
@@ -5,7 +5,13 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import {
|
||||
danmakuAPI,
|
||||
type DanmakuAnime,
|
||||
type DanmakuConfig,
|
||||
type DanmakuLoadedInfo,
|
||||
type DanmakuSettingsPatch,
|
||||
} from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
@@ -55,6 +61,12 @@ type PlaybackProgressSession = {
|
||||
sequence: number
|
||||
}
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.min(1, Math.max(0, parsed))
|
||||
}
|
||||
|
||||
function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
|
||||
const randomID =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
@@ -94,7 +106,11 @@ export function PlayerPage() {
|
||||
)
|
||||
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
|
||||
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
|
||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||
normalizePlayerVolume(authUser?.player_volume),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
@@ -131,6 +147,10 @@ export function PlayerPage() {
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
const [danmakuSource, setDanmakuSource] = useState('')
|
||||
const [danmakuAppID, setDanmakuAppID] = useState('')
|
||||
const [danmakuAppKeyConfigured, setDanmakuAppKeyConfigured] = useState(false)
|
||||
const [playerSettingsSaving, setPlayerSettingsSaving] = useState(false)
|
||||
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
@@ -181,51 +201,151 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [setAuthUser])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
useEffect(() => {
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const applyDanmakuConnectionConfig = useCallback((cfg: DanmakuConfig) => {
|
||||
setDanmakuSource(cfg.source ?? '')
|
||||
setDanmakuAppID(cfg.app_id ?? '')
|
||||
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
|
||||
}, [])
|
||||
|
||||
// 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
|
||||
const saveDanmakuSettings = useCallback(
|
||||
async (patch: DanmakuSettingsPatch, failureMessage = '播放器设置保存失败,请重试') => {
|
||||
setPlayerSettingsSaving(true)
|
||||
try {
|
||||
const cfg = await danmakuAPI.updateSettings(patch)
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
return cfg
|
||||
} catch (error) {
|
||||
toast.error(failureMessage)
|
||||
throw error
|
||||
} finally {
|
||||
setPlayerSettingsSaving(false)
|
||||
}
|
||||
},
|
||||
[applyDanmakuConnectionConfig],
|
||||
)
|
||||
|
||||
// 一次读取当前用户的音量、弹幕渲染参数、服务地址和凭据配置状态。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return
|
||||
const volume = normalizePlayerVolume(cfg.volume)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
setPlayerVolume(volume)
|
||||
}
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持默认(不合并),不影响播放。
|
||||
// 配置读取失败时保持本地默认值,不影响播放。
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyDanmakuConnectionConfig, setAuthUser, authUser?.id])
|
||||
|
||||
const changePlayerVolume = useCallback((next: number) => {
|
||||
playerVolumeTouchedRef.current = true
|
||||
setPlayerVolume(normalizePlayerVolume(next))
|
||||
}, [])
|
||||
|
||||
const commitPlayerVolume = useCallback(
|
||||
(next: number) => {
|
||||
playerVolumeTouchedRef.current = true
|
||||
const volume = normalizePlayerVolume(next)
|
||||
setPlayerVolume(volume)
|
||||
void danmakuAPI
|
||||
.updateSettings({ volume })
|
||||
.then((cfg) => {
|
||||
const saved = normalizePlayerVolume(cfg.volume)
|
||||
setPlayerVolume(saved)
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: saved })
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('音量保存失败,请重试')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const saveDanmakuAdvanced = useCallback(
|
||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||
const patch: DanmakuSettingsPatch = {
|
||||
source: values.source.trim(),
|
||||
app_id: values.appId.trim(),
|
||||
}
|
||||
if (values.clearAppKey) {
|
||||
patch.clear_app_key = true
|
||||
} else if (values.appKey.trim()) {
|
||||
patch.app_key = values.appKey.trim()
|
||||
}
|
||||
const cfg = await saveDanmakuSettings(patch, '弹幕服务设置保存失败,请重试')
|
||||
setDanmakuSource(cfg.source ?? '')
|
||||
setDanmakuAppID(cfg.app_id ?? '')
|
||||
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
|
||||
// 地址或凭据变化后立即按新配置重新抓取当前对象的弹幕。
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
toast.success('弹幕服务设置已保存')
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
|
||||
const danmakuChangeMergeSources = useCallback((next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
danmakuAPI
|
||||
.updateSettings({ mergeSources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
// 保存失败时保持原值,用户可重试。
|
||||
})
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
}, [])
|
||||
const danmakuChangeMergeSources = useCallback(
|
||||
(next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
saveDanmakuSettings({ merge_sources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const danmakuChangeEnabled = useCallback(
|
||||
(next: boolean) => {
|
||||
setDanmakuEnabled(next)
|
||||
void saveDanmakuSettings({ enabled: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuArea = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ area: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuOpacity = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ opacity: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuFontSize = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ font_size: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
@@ -783,7 +903,8 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [nextEpisode, nextEpisodeTitle, playEpisode])
|
||||
|
||||
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集
|
||||
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集。
|
||||
// 左右方向键的回退/快进在播放器控制栏里处理。
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
@@ -1037,6 +1158,9 @@ export function PlayerPage() {
|
||||
onSubtitleStyleChange={changeSubtitleStyle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
playerVolume={playerVolume}
|
||||
onPlayerVolumeChange={changePlayerVolume}
|
||||
onPlayerVolumeCommit={commitPlayerVolume}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
@@ -1075,16 +1199,24 @@ export function PlayerPage() {
|
||||
open={danmakuOpen}
|
||||
onClose={() => setDanmakuOpen(false)}
|
||||
enabled={danmakuEnabled}
|
||||
onToggleEnabled={setDanmakuEnabled}
|
||||
onToggleEnabled={danmakuChangeEnabled}
|
||||
search={danmakuSearch ?? ''}
|
||||
onSearch={searchDanmaku}
|
||||
searching={danmakuSearching}
|
||||
area={danmakuArea}
|
||||
onAreaChange={setDanmakuArea}
|
||||
onAreaCommit={commitDanmakuArea}
|
||||
opacity={danmakuOpacity}
|
||||
onOpacityChange={setDanmakuOpacity}
|
||||
onOpacityCommit={commitDanmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
onFontSizeCommit={commitDanmakuFontSize}
|
||||
source={danmakuSource}
|
||||
appId={danmakuAppID}
|
||||
appKeyConfigured={danmakuAppKeyConfigured}
|
||||
settingsSaving={playerSettingsSaving}
|
||||
onSaveAdvanced={saveDanmakuAdvanced}
|
||||
candidates={danmakuCandidates}
|
||||
alternatives={danmakuAlternatives}
|
||||
mergeSources={danmakuMergeSources}
|
||||
|
||||
@@ -147,6 +147,9 @@ type PlayerVideoStageProps = {
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
playerVolume: number
|
||||
onPlayerVolumeChange: (volume: number) => void
|
||||
onPlayerVolumeCommit: (volume: number) => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
@@ -192,6 +195,9 @@ export function PlayerVideoStage({
|
||||
onSubtitleStyleChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
playerVolume,
|
||||
onPlayerVolumeChange,
|
||||
onPlayerVolumeCommit,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
@@ -631,6 +637,9 @@ export function PlayerVideoStage({
|
||||
</div>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
volume={playerVolume}
|
||||
onVolumeChange={onPlayerVolumeChange}
|
||||
onVolumeCommit={onPlayerVolumeCommit}
|
||||
uiVisible={controlsVisible}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
subs={subs}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
// 弹幕配置:对接 dandanplay(弹弹play)协议弹幕源 + 渲染通用功能。
|
||||
// 来源地址留空时使用官方 https://api.dandanplay.net;填写任何符合
|
||||
// dandanplay 协议(/api/v2/search/episodes + /api/v2/comment/:id)的服务地址均可。
|
||||
export const danmakuSettingsGroup: SettingGroup = {
|
||||
key: 'danmaku',
|
||||
label: '弹幕',
|
||||
description: '弹幕来源(dandanplay 协议)与渲染参数(透明度 / 字号 / 显示区域)',
|
||||
items: [
|
||||
{
|
||||
key: 'danmaku.enabled',
|
||||
label: '启用弹幕',
|
||||
type: 'toggle',
|
||||
hint: '关闭后播放页不拉取、不渲染弹幕',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.source',
|
||||
label: '弹幕服务地址(dandanplay 协议)',
|
||||
type: 'text',
|
||||
hint: '留空使用官方 https://api.dandanplay.net。可填写自建或第三方符合 dandanplay 协议的服务地址(含 /api/v2/search/episodes 搜索与 /api/v2/comment/:id 弹幕接口)。播放时按视频名称搜索番剧并拉取 Bilibili 格式 XML 弹幕。',
|
||||
placeholder: 'https://api.dandanplay.net',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.app_id',
|
||||
label: 'AppId(弹弹play 开放 API)',
|
||||
type: 'text',
|
||||
hint: '弹弹play DevCenter 申请的应用 ID(https://doc.dandanplay.com/open/)。官方接口要求应用认证,留空使用内置凭据(签名认证,开箱即用);填写自己的 AppId/AppKey 可覆盖内置凭据。仅对官方 api.dandanplay.net 生效,第三方协议源不会携带凭据。',
|
||||
placeholder: '在 DevCenter 申请的应用 ID',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.app_key',
|
||||
label: 'AppKey(弹弹play 应用密钥)',
|
||||
type: 'text',
|
||||
hint: '与 AppId 配套的 AppSecret,只保存在服务器上用于计算请求签名(base64(sha256(AppId+Timestamp+Path+Secret))),不会下发到播放器。',
|
||||
placeholder: '在 DevCenter 申请的应用密钥',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.opacity',
|
||||
label: '弹幕透明度',
|
||||
type: 'number',
|
||||
hint: '0.1 ~ 1,数值越小越透明(1 为完全不透明)',
|
||||
defaultValue: '1',
|
||||
placeholder: '1',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.font_size',
|
||||
label: '弹幕字号',
|
||||
type: 'number',
|
||||
hint: '弹幕渲染字号(px),默认 24',
|
||||
defaultValue: '24',
|
||||
placeholder: '24',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.area',
|
||||
label: '弹幕显示区域',
|
||||
type: 'number',
|
||||
hint: '0 ~ 1,弹幕占视频高度比例(1 为全屏,0.5 为上半屏)',
|
||||
defaultValue: '1',
|
||||
placeholder: '1',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
||||
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
|
||||
import { generalSettingsGroup } from './settingsGroupGeneral'
|
||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
@@ -26,7 +25,6 @@ export const GROUPS: SettingGroup[] = [
|
||||
databaseSettingsGroup,
|
||||
apiConfigsSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
danmakuSettingsGroup,
|
||||
adultSettingsGroup,
|
||||
aboutSettingsGroup,
|
||||
]
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface User {
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||
player_volume?: number
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
|
||||
@@ -3,4 +3,19 @@
|
||||
// embyremote~{accountID}~{remoteID}。
|
||||
export function isRemoteEmbyID(id?: string | null): boolean {
|
||||
return Boolean(id && id.startsWith('embyremote~'))
|
||||
}
|
||||
|
||||
// partitionPreviewIDs 把本地库和远程库拆开。混在一个预览请求里时,本地 SQL
|
||||
// 会等最慢的远程 Emby 回来才一起返回。
|
||||
export function partitionPreviewIDs(ids: string[]): string[][] {
|
||||
const local: string[] = []
|
||||
const remote: string[] = []
|
||||
for (const id of ids) {
|
||||
if (isRemoteEmbyID(id)) remote.push(id)
|
||||
else local.push(id)
|
||||
}
|
||||
const batches: string[][] = []
|
||||
if (local.length > 0) batches.push(local)
|
||||
if (remote.length > 0) batches.push(remote)
|
||||
return batches
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { runWithoutGlobalVideoFrame } from './withoutVideoFrame.ts'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`runWithoutGlobalVideoFrame: ${name}`)
|
||||
}
|
||||
|
||||
const FakeVideoFrame = function VideoFrame() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'VideoFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: FakeVideoFrame,
|
||||
})
|
||||
|
||||
let seenInside = typeof (globalThis as { VideoFrame?: unknown }).VideoFrame
|
||||
const result = runWithoutGlobalVideoFrame(() => {
|
||||
seenInside = typeof (globalThis as { VideoFrame?: unknown }).VideoFrame
|
||||
return 7
|
||||
})
|
||||
|
||||
check('hides VideoFrame inside the callback', seenInside === 'undefined')
|
||||
check('returns the callback result', result === 7)
|
||||
check(
|
||||
'restores VideoFrame afterwards',
|
||||
(globalThis as { VideoFrame?: unknown }).VideoFrame === FakeVideoFrame,
|
||||
)
|
||||
|
||||
console.log('withoutVideoFrame.test.ts ok')
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* JASSUB probes color space with `new VideoFrame(video)` during construct.
|
||||
* Direct-play 302s to a CDN / remote Emby taint <video>, and Chrome throws
|
||||
* SecurityError. Overlay rendering uses requestVideoFrameCallback metadata,
|
||||
* not pixels, so hide VideoFrame for the sync constructor call.
|
||||
*
|
||||
* ponytail: skips color-space matching for all ASS playback, not only tainted
|
||||
* sources. 302 completes after construct, so we cannot probe currentSrc first.
|
||||
* Upgrade: canvas-only JASSUB driven by rVFC metadata when the element taints.
|
||||
*/
|
||||
export function runWithoutGlobalVideoFrame<T>(fn: () => T): T {
|
||||
const desc = Object.getOwnPropertyDescriptor(globalThis, 'VideoFrame')
|
||||
Object.defineProperty(globalThis, 'VideoFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
})
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
if (desc) {
|
||||
Object.defineProperty(globalThis, 'VideoFrame', desc)
|
||||
} else {
|
||||
delete (globalThis as { VideoFrame?: unknown }).VideoFrame
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user