Compare commits

...

6 Commits

Author SHA1 Message Date
truewhile 5c85478883 1 2026-09-14 12:33:59 +08:00
truewhile fd54d42db7 优化 2026-09-14 00:02:46 +08:00
truewhile 4595b7c5be 优化 2026-09-13 23:45:21 +08:00
truewhile ee3927cdf1 优化 2026-09-13 23:39:07 +08:00
truewhile 4b34fc4616 优化 2026-09-13 23:11:28 +08:00
truewhile b7f1cd85f8 支持按用户保存播放器与弹幕偏好
在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。
返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。
结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
2026-09-13 23:01:43 +08:00
47 changed files with 1723 additions and 332 deletions
+5
View File
@@ -99,6 +99,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
+12 -16
View File
@@ -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)
}
}
+55
View File
@@ -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)
}
}
+10 -6
View File
@@ -33,10 +33,10 @@ type createLibraryReq struct {
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
@@ -115,7 +115,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return
}
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
@@ -202,7 +206,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
}()
+11 -4
View File
@@ -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.
+49
View File
@@ -43,6 +43,7 @@ type MediaQueryFilter struct {
IncludeNSFW bool
AllowedLibraryIDs []string
HiddenLibraryIDs []string
SeriesID string
}
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
@@ -55,6 +56,9 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
}
return q
}
@@ -120,6 +124,47 @@ func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context,
return items, err
}
// ListVersionCandidates loads a bounded candidate set for version grouping
// using the strongest identity stored on the row. Returning ok=false keeps the
// caller's full-library fallback for rows without external IDs or SeriesID.
func (r *MediaRepository) ListVersionCandidates(ctx context.Context, libraryIDs []string, media model.Media, limit int) ([]model.Media, bool, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, false, nil
}
if limit <= 0 {
limit = 5000
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
found := true
switch {
case strings.TrimSpace(media.SeriesID) != "":
q = q.Where("series_id = ?", strings.TrimSpace(media.SeriesID))
case media.TMDbID > 0:
q = q.Where("tm_db_id = ?", media.TMDbID)
case media.BangumiID > 0:
q = q.Where("bangumi_id = ?", media.BangumiID)
case strings.TrimSpace(media.DoubanID) != "":
q = q.Where("douban_id = ?", strings.TrimSpace(media.DoubanID))
case strings.TrimSpace(media.TheTVDBID) != "":
q = q.Where("thetvdb_id = ?", strings.TrimSpace(media.TheTVDBID))
default:
found = false
}
if !found {
return items, false, nil
}
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").
Limit(limit).
Find(&items).Error
return items, true, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
@@ -216,6 +261,10 @@ func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) {
parts = append(parts, "library_id IN ?")
args = append(args, filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
parts = append(parts, "series_id = ?")
args = append(args, seriesID)
}
return strings.Join(parts, " AND "), args
}
@@ -146,6 +146,9 @@ func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.D
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("media.series_id = ?", seriesID)
}
return q
}
+247 -40
View File
@@ -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
+40
View File
@@ -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"))
}
+4 -2
View File
@@ -600,10 +600,12 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
seasonNum := m.SeasonNum
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonNum = embySeasonNumForMedia(m)
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
@@ -648,7 +650,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum,
"ParentIndexNumber": seasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": runTimeTicks,
@@ -660,7 +662,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"Path": m.Path,
"ParentId": parentID,
"SeasonId": seasonID,
"SeasonName": seasonName(m.SeasonNum),
"SeasonName": seasonName(seasonNum),
"SeriesId": seriesID,
"SeriesName": seriesName,
"ImageTags": imageTags,
+9 -7
View File
@@ -143,8 +143,11 @@ func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id,
}
matched := make([]string, 0, 1)
for _, cand := range candidates {
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
matched = append(matched, cand.SeriesID)
for _, seasonNum := range embySeasonCandidates(cand.SeasonNum) {
if seasonID(cand.SeriesID, seasonNum) == id {
matched = append(matched, cand.SeriesID)
break
}
}
}
for _, matchedSeries := range matched {
@@ -257,10 +260,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
bySeason := map[int]*embySeasonGroup{}
order := []int{}
for _, episode := range series.Episodes {
seasonNum := episode.SeasonNum
if seasonNum < 0 {
seasonNum = 1
}
seasonNum := embySeasonNumForMedia(&episode)
season, ok := bySeason[seasonNum]
if !ok {
season = &embySeasonGroup{
@@ -276,7 +276,9 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
}
season.Episodes = append(season.Episodes, episode)
}
sort.Ints(order)
sort.SliceStable(order, func(i, j int) bool {
return embySeasonSortOrder(order[i]) < embySeasonSortOrder(order[j])
})
out := make([]embySeasonGroup, 0, len(order))
for _, seasonNum := range order {
out = append(out, *bySeason[seasonNum])
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"sort"
"testing"
"time"
@@ -215,6 +216,147 @@ func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
}
}
func TestEmbySeparatesOVAAndOADFromSeasonOne(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "动漫", Path: `/media/影视库/动漫`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for _, media := range []model.Media{
{
Base: model.Base{ID: "tolove-s01e01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru S01E01.mkv.strm`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "tolove-ova01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/S01/To LOVE-Ru [OVA01].mkv.strm`,
SeasonNum: 0,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "tolove-oad01"},
LibraryID: lib.ID,
Title: "出包王女",
Path: `/media/影视库/动漫/出包王女/OAD/To LOVE-Ru Darkness [OAD01].mkv.strm`,
SeasonNum: -1,
EpisodeNum: 1,
},
} {
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50})
if err != nil {
t.Fatalf("library items: %v", err)
}
rootItems := root["Items"].([]map[string]any)
if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" {
t.Fatalf("expected one series card, got %#v", rootItems)
}
if rootItems[0]["ChildCount"] != 3 {
t.Fatalf("series ChildCount = %#v, want 3", rootItems[0]["ChildCount"])
}
seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50})
if err != nil {
t.Fatalf("series seasons: %v", err)
}
seasonItems := seasons["Items"].([]map[string]any)
if len(seasonItems) != 3 {
t.Fatalf("expected season 1, OVA and OAD separately, got %#v", seasonItems)
}
for i, want := range []int{1, embySeasonOVA, embySeasonOAD} {
if seasonItems[i]["IndexNumber"] != want {
t.Fatalf("season order [%d] = %#v, want %d", i, seasonItems[i], want)
}
}
seasonByIndex := make(map[int]map[string]any, len(seasonItems))
for _, season := range seasonItems {
index, ok := season["IndexNumber"].(int)
if !ok {
t.Fatalf("season index has unexpected type: %#v", season)
}
seasonByIndex[index] = season
}
for index, name := range map[int]string{
1: "第 1 季",
embySeasonOVA: "OVA",
embySeasonOAD: "OAD",
} {
season := seasonByIndex[index]
if season == nil || season["Name"] != name || season["ChildCount"] != 1 {
t.Fatalf("season %d = %#v, want name=%q with one episode", index, season, name)
}
episodes, err := svc.Items(t.Context(), ItemsParams{
ParentID: season["Id"].(string),
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 50,
})
if err != nil {
t.Fatalf("season %d episodes: %v", index, err)
}
episodeItems := episodes["Items"].([]map[string]any)
if len(episodeItems) != 1 || episodeItems[0]["ParentIndexNumber"] != index {
t.Fatalf("season %d episodes = %#v", index, episodeItems)
}
}
}
func TestEmbySeasonSortOrderMatchesWeb(t *testing.T) {
seasons := []int{
embySeasonNCED,
embySeasonOAD,
2,
embySeasonTheatrical,
1,
embySeasonGenericSpecial,
embySeasonOVA,
}
sort.SliceStable(seasons, func(i, j int) bool {
return embySeasonSortOrder(seasons[i]) < embySeasonSortOrder(seasons[j])
})
want := []int{
1,
2,
embySeasonGenericSpecial,
embySeasonTheatrical,
embySeasonOVA,
embySeasonOAD,
embySeasonNCED,
}
for i := range want {
if seasons[i] != want[i] {
t.Fatalf("season order = %#v, want %#v", seasons, want)
}
}
}
func TestEmbySeasonCandidatesCoverPersistedSpecialNumbers(t *testing.T) {
for _, persisted := range []int{0, -1} {
candidates := embySeasonCandidates(persisted)
found := map[int]bool{}
for _, seasonNum := range candidates {
found[seasonNum] = true
}
if !found[embySeasonOVA] || !found[embySeasonOAD] {
t.Fatalf("candidates for persisted season %d = %#v, want OVA and OAD", persisted, candidates)
}
}
if candidates := embySeasonCandidates(2); len(candidates) != 1 || candidates[0] != 2 {
t.Fatalf("regular season candidates = %#v, want [2]", candidates)
}
}
func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
+135 -8
View File
@@ -22,7 +22,7 @@ func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) stri
}
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
return seasonID(e.seriesIDForMedia(ctx, m), embySeasonNumForMedia(m))
}
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
@@ -100,23 +100,150 @@ func stableEmbyID(prefix string, parts ...string) string {
return prefix + hex.EncodeToString(h.Sum(nil))[:32]
}
func seasonID(seriesID string, seasonNum int) string {
if seasonNum < 0 {
seasonNum = 1
const (
embySeasonTheatrical = -1
embySeasonOVA = -2
embySeasonOAD = -3
embySeasonOVD = -4
embySeasonONA = -5
embySeasonExtra = -6
embySeasonBonus = -7
embySeasonOmake = -8
embySeasonPictureDrama = -9
embySeasonNCOP = -10
embySeasonNCED = -11
embySeasonGenericSpecial = 0
)
// embySeasonNumForMedia keeps MeBox's special categories distinct in the Emby
// hierarchy. Databases may store every special as season 0 or -1, so the path
// classification is authoritative when it identifies a special category.
func embySeasonNumForMedia(m *model.Media) int {
if m == nil {
return 0
}
switch mediaSpecialKind(m.Path) {
case mediaSpecialTheatrical:
return embySeasonTheatrical
case mediaSpecialOVA:
return embySeasonOVA
case mediaSpecialOAD:
return embySeasonOAD
case mediaSpecialOVD:
return embySeasonOVD
case mediaSpecialONA:
return embySeasonONA
case mediaSpecialExtra:
return embySeasonExtra
case mediaSpecialBonus:
return embySeasonBonus
case mediaSpecialOmake:
return embySeasonOmake
case mediaSpecialPicture:
return embySeasonPictureDrama
case mediaSpecialNCOP:
return embySeasonNCOP
case mediaSpecialNCED:
return embySeasonNCED
case mediaSpecialGeneric:
return embySeasonGenericSpecial
}
if m.SeasonNum < 0 {
return embySeasonGenericSpecial
}
return m.SeasonNum
}
func embySeasonCandidates(seasonNum int) []int {
if seasonNum > 0 {
return []int{seasonNum}
}
return []int{
embySeasonGenericSpecial,
embySeasonTheatrical,
embySeasonOVA,
embySeasonOAD,
embySeasonOVD,
embySeasonONA,
embySeasonExtra,
embySeasonBonus,
embySeasonOmake,
embySeasonPictureDrama,
embySeasonNCOP,
embySeasonNCED,
}
}
func seasonID(seriesID string, seasonNum int) string {
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
}
func seasonName(seasonNum int) string {
if seasonNum == 0 {
switch seasonNum {
case embySeasonGenericSpecial:
return "特别篇"
}
if seasonNum < 0 {
seasonNum = 1
case embySeasonTheatrical:
return "剧场版"
case embySeasonOVA:
return "OVA"
case embySeasonOAD:
return "OAD"
case embySeasonOVD:
return "OVD"
case embySeasonONA:
return "ONA"
case embySeasonExtra:
return "Extra"
case embySeasonBonus:
return "Bonus"
case embySeasonOmake:
return "Omake"
case embySeasonPictureDrama:
return "Picture Drama"
case embySeasonNCOP:
return "NCOP"
case embySeasonNCED:
return "NCED"
}
return fmt.Sprintf("第 %d 季", seasonNum)
}
// embySeasonSortOrder mirrors the web UI's seasonSortOrder: regular seasons
// first in ascending order, followed by specials in a stable category order.
func embySeasonSortOrder(seasonNum int) int {
if seasonNum > 0 {
return seasonNum
}
switch seasonNum {
case embySeasonGenericSpecial:
return 1000
case embySeasonTheatrical:
return 1001
case embySeasonOVA:
return 1002
case embySeasonOAD:
return 1003
case embySeasonOVD:
return 1004
case embySeasonONA:
return 1005
case embySeasonExtra:
return 1006
case embySeasonBonus:
return 1007
case embySeasonOmake:
return 1008
case embySeasonPictureDrama:
return 1009
case embySeasonNCOP:
return 1010
case embySeasonNCED:
return 1011
default:
return 2000
}
}
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
switch primarySupportedEmbySort(p.SortBy, false) {
case "sortname", "name":
+7 -5
View File
@@ -24,17 +24,19 @@ import (
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
)
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
fetchGroup singleflight.Group
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
+35 -1
View File
@@ -95,7 +95,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath)
if err != nil {
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
@@ -206,6 +206,40 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
return nil, "", "", lastErr
}
type sharedRemoteImageResult struct {
data []byte
contentType string
contentLength string
}
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
// upstream image. A poster can appear in the hero, a shelf and the detail page
// at the same time; without this guard every resize variant may fetch the same
// original before the first cache write finishes.
func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) {
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath)
if err != nil {
return nil, err
}
return sharedRemoteImageResult{
data: data,
contentType: contentType,
contentLength: contentLength,
}, nil
})
if err != nil {
return nil, "", "", err
}
result, ok := value.(sharedRemoteImageResult)
if !ok {
return nil, "", "", errors.New("upstream image fetch failed")
}
return result.data, result.contentType, result.contentLength, nil
}
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
u, err := p.validateURL(raw)
@@ -101,7 +101,21 @@ func localBackdropCandidates(mediaPath string) []string {
seen[key] = struct{}{}
names = append(names, name)
}
_, episode := ParseEpisode(mediaPath)
for _, base := range mediaSidecarBaseVariants(mediaPath) {
// Jellyfin/Emby commonly stores an episode still beside the video as
// "<episode>-thumb.jpg" or "<episode>.jpg". Episode-specific artwork
// must win over a series-level fanart.jpg in the parent folder,
// otherwise a rescan flattens every episode to the same backdrop.
add(base + "-thumb")
add(base + ".thumb")
if episode > 0 {
add(base + "-still")
add(base + ".still")
add(base + "-scene")
add(base + ".scene")
add(base)
}
add(base + "-fanart")
add(base + ".fanart")
add(base + "-backdrop")
+2 -2
View File
@@ -236,8 +236,8 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if got.PosterURL != showPoster {
t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
}
if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
if got.BackdropURL != episodeThumb {
t.Fatalf("BackdropURL = %q, want episode thumb %q, not series backdrop %q", got.BackdropURL, episodeThumb, backdrop)
}
}
+7 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
@@ -11,10 +12,12 @@ import (
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
}
type MediaVisibility struct {
+4
View File
@@ -31,6 +31,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:list:" + hex.EncodeToString(sum[:])
}
@@ -51,6 +52,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim
fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:preview:" + hex.EncodeToString(sum[:])
}
@@ -82,6 +84,7 @@ func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter reposito
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:lib-counts:" + hex.EncodeToString(sum[:])
}
@@ -134,6 +137,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
})
}
+43 -21
View File
@@ -14,10 +14,6 @@ type LibraryPreviewItem struct {
Cards []SeriesCard `json:"cards"`
}
type libraryPreviewCacheValue struct {
Items []LibraryPreviewItem `json:"items"`
}
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
@@ -68,27 +64,34 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
if len(libraries) == 0 {
return out, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts)
var cached libraryPreviewCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
}
libIDs := make([]string, 0, len(libraries))
pending := make([]model.Library, 0, len(libraries))
pendingSet := make(map[string]struct{}, len(libraries))
for i, lib := range libraries {
out[i] = LibraryPreviewItem{
Library: lib,
Total: 0,
Cards: []SeriesCard{},
}
libIDs = append(libIDs, lib.ID)
itemKey := s.libraryPreviewCacheKey([]model.Library{lib}, cardLimit, filter, includeCounts)
if s.cache != nil && s.cache.GetJSON(ctx, itemKey, &out[i]) {
continue
}
pending = append(pending, lib)
pendingSet[lib.ID] = struct{}{}
}
if len(pending) == 0 {
return out, nil
}
libIDs := make([]string, len(pending))
for i := range pending {
libIDs[i] = pending[i].ID
}
if includeCounts {
@@ -97,9 +100,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
return nil, err
}
for i := range out {
if total, ok := counts[out[i].ID]; ok {
out[i].Total = total
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
out[i].Total = counts[out[i].ID]
}
}
@@ -107,8 +111,16 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
// library with a very long series cannot turn a homepage request into a full
// 50k-row scan merely to find another distinct card.
fetchCount := cardLimit * 12
if fetchCount < 120 {
fetchCount = 120
minFetchCount := 120
if cardLimit <= 2 {
minFetchCount = 24
} else if cardLimit <= 4 {
// 首页/媒体库入口的马赛克只需要少量代表图,没必要为暂时不会
// 展示的横向货架扫描一整批 120 行候选。
minFetchCount = 48
}
if fetchCount < minFetchCount {
fetchCount = minFetchCount
} else if fetchCount > 400 {
fetchCount = 400
}
@@ -120,6 +132,9 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
for i := range out {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
@@ -129,8 +144,15 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
s.attachLibraryMetadata(ctx, allPreviewItems)
for i := range out {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
continue
}
cards := groupMediaSeriesCards(items)
@@ -141,10 +163,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
cards = []SeriesCard{}
}
out[i].Cards = cards
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
}
return out, nil
+29
View File
@@ -180,3 +180,32 @@ func TestListLibraryPreviewsSkipsTotals(t *testing.T) {
t.Fatalf("preview-only result = %#v, want one card and no count", previews)
}
}
func TestListLibraryPreviewsCachesEachLibraryIndependently(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "预览电影",
Path: "/media/movies/预览电影.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
visibility := MediaVisibility{IncludeNSFW: true}
if _, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, visibility, 10); err != nil {
t.Fatal(err)
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
key := svc.libraryPreviewCacheKey([]model.Library{lib}, 10, filter, false)
var cached LibraryPreviewItem
if !svc.cache.GetJSON(t.Context(), key, &cached) || len(cached.Cards) != 1 {
t.Fatalf("per-library preview cache = %#v, want one cached card", cached)
}
}
+28 -11
View File
@@ -77,22 +77,33 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
}
}
}
}
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
}
return grouped, nil
})
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
if grouped, ok := value.([]MediaItem); ok {
return grouped, nil
}
return grouped, nil
return nil, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
@@ -168,10 +179,16 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
if err != nil {
return nil, err
}
}
s.attachLibraryMetadata(ctx, candidates)
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
+95 -22
View File
@@ -36,6 +36,35 @@ type SeriesCard struct {
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
// SeriesCardView is the compact payload used by homepage and library preview
// endpoints. LinkMedia is only needed to resolve the target library ID, so
// sending the full media row twice roughly doubles the preview JSON for no UI
// benefit.
type SeriesCardView struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
if len(cards) == 0 {
return []SeriesCardView{}
}
out := make([]SeriesCardView, len(cards))
for i, card := range cards {
out[i] = SeriesCardView{
Key: card.Key,
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
LastAddedAt: card.LastAddedAt,
}
}
return out
}
type seriesCardGroup struct {
card SeriesCard
latest time.Time
@@ -45,34 +74,45 @@ type seriesCardGroup struct {
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
value, err, _ := s.libraryRowsFlight.Do(cacheKey, func() (any, error) {
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
}
}
}
}
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
rows, _, err := s.listAllMediaVisible(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
})
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
if cached, ok := value.(*libraryRowsCacheValue); ok {
return cached, nil
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
return nil, nil
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
@@ -179,6 +219,30 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if target.LibraryID == "" {
return []model.Media{*target}, nil
}
// 电影、音乐等单条目库不需要为了返回自身而加载整库。详情页会并行
// 请求 /media/:id/episodes,未短路时每次冷缓存都会触发一次全库分组。
if lib, err := s.repo.Library.FindByID(ctx, target.LibraryID); err == nil && lib != nil &&
libraryUsesSingleMediaRows(lib.Type) &&
strings.TrimSpace(target.SeriesID) == "" &&
!mediaLooksEpisodicForGrouping(*target) {
return []model.Media{*target}, nil
}
if seriesID := strings.TrimSpace(target.SeriesID); seriesID != "" {
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, target.LibraryID)
if err == nil && len(libraryIDs) > 0 {
rows, queryErr := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
SeriesID: seriesID,
})
if queryErr == nil && len(rows) > 1 {
s.attachLibraryMetadata(ctx, rows)
sortEpisodesForDisplay(rows)
return rows, nil
}
}
}
cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility)
if err != nil {
return nil, err
@@ -253,6 +317,15 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
return out, nil
}
func libraryUsesSingleMediaRows(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies", "music", "adult":
return true
default:
return false
}
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
+83
View File
@@ -594,6 +594,89 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
}
}
func TestListMediaEpisodesMovieTypeSkipsFullLibraryIndex(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movie-fast"}, Name: "电影", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
movie := model.Media{
Base: model.Base{ID: "movie-fast"},
LibraryID: lib.ID,
Title: "电影",
Path: "/media/movies/电影/电影.mkv",
}
if err := repos.DB.Create(&movie).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), movie.ID, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != movie.ID {
t.Fatalf("ListMediaEpisodes got %#v, want the movie itself", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("movie detail should not build a full-library episode index")
}
}
func TestListMediaEpisodesUsesSeriesIDFastPath(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-series-fast"}, Name: "剧集", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
Base: model.Base{ID: "episode-2"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "episode-1"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "other-series"},
LibraryID: lib.ID,
SeriesID: "other",
Title: "其他剧",
Path: "/media/tv/其他剧/Season 1/其他剧.S01E01.mkv",
},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), "episode-1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 2 || eps[0].ID != "episode-1" || eps[1].ID != "episode-2" {
t.Fatalf("SeriesID fast path got %#v, want both episodes in order", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("SeriesID detail fast path should not build a full-library episode index")
}
}
func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
@@ -268,6 +268,74 @@ func TestScanLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) {
}
}
func TestScanLibraryPrefersEpisodeArtworkOverSeriesFanart(t *testing.T) {
for _, tc := range []struct {
name string
episodeArt string
}{
{name: "thumb suffix", episodeArt: "Show - S01E01-thumb.jpg"},
{name: "video stem", episodeArt: "Show - S01E01.jpg"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "Show", "Season 01")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "Show - S01E01.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(
`<episodedetails><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`,
), 0o644); err != nil {
t.Fatal(err)
}
episodeArt := filepath.Join(seasonDir, tc.episodeArt)
if err := os.WriteFile(episodeArt, []byte("episode still"), 0o644); err != nil {
t.Fatal(err)
}
seriesFanart := filepath.Join(root, "Show", "fanart.jpg")
if err := os.WriteFile(seriesFanart, []byte("series backdrop"), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
existing := model.Media{
LibraryID: lib.ID,
Title: "Show",
Path: mediaPath,
SizeBytes: 1,
SeasonNum: 1,
EpisodeNum: 1,
BackdropURL: "https://image.example/episode-still.jpg",
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&existing).Error; err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "id = ?", existing.ID).Error; err != nil {
t.Fatal(err)
}
if media.BackdropURL != episodeArt {
t.Fatalf("backdrop_url = %q, want episode artwork %q (series fanart %q must not win)", media.BackdropURL, episodeArt, seriesFanart)
}
})
}
}
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
+32 -20
View File
@@ -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),
}
+5
View File
@@ -64,6 +64,11 @@ export const playbackAPI = {
listFavouriteIDs: () =>
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
favouriteStatus: (mediaId: string) =>
api
.get<{ favourite: boolean }>(`/media/${encodeURIComponent(mediaId)}/favorite/status`)
.then((r) => r.data.favourite),
listPlaylists: () =>
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
+16 -12
View File
@@ -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) {
+1 -1
View File
@@ -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.
+53 -19
View File
@@ -58,6 +58,10 @@ function isEditableTarget(target: EventTarget | null): boolean {
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[]
@@ -92,6 +96,9 @@ type PlayerControlsProps = {
export function PlayerControls({
videoRef,
volume: volumeProp = 1,
onVolumeChange,
onVolumeCommit,
uiVisible,
onUiVisibleChange,
subs,
@@ -128,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)
@@ -181,6 +188,17 @@ export function PlayerControls({
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
@@ -336,19 +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)
}
applySeekRef.current = applyAbsoluteSeek
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)
@@ -474,10 +498,17 @@ export function PlayerControls({
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 = () => {
@@ -803,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="音量"
/>
+162 -3
View File
@@ -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
@@ -143,11 +196,12 @@ export function PlayerDanmakuPanel({
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-auto sm:w-80"
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto overscroll-contain rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:bottom-auto sm:right-4 sm:top-16 sm:w-80 sm:max-h-[calc(100%-5rem)]"
>
<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>
+18 -4
View File
@@ -112,16 +112,25 @@ export function HomePage() {
try {
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
const accepted = rows.filter(
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
)
accepted.forEach((row) => {
fetchedPreviewLimitsRef.current.set(
row.id,
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
)
})
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
for (const row of accepted) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
return accepted.length > 0 ? next : prev
})
} catch {
// 单个批次失败不影响其他批次;导航回来时会重试。
@@ -155,8 +164,13 @@ export function HomePage() {
.slice(0, 20)
.filter((l) => !l.cover_url)
.map((l) => l.id)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds, ...topGridLibIds]))
void fetchPreviews(initialTargets, 10)
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
const shelfTargetSet = new Set(shelfTargets)
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
// 为暂时不会出现的货架预取完整卡片窗口。
void fetchPreviews(shelfTargets, 10)
void fetchPreviews(gridTargets, 2)
}, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
+2 -2
View File
@@ -359,13 +359,13 @@ export function HomeLibrariesSection({
libraries: Library[]
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
libraryCounts: Record<string, number>
onNeedPreviews?: (ids: string[]) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
const PAGE_SIZE = 20
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
const effectivePage = Math.min(currentPage, totalPages)
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
const pagedLibraries = useMemo<Library[]>(() => {
const start = (effectivePage - 1) * PAGE_SIZE
+31 -10
View File
@@ -26,37 +26,58 @@ export function LibrariesPage() {
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
const targets = ids.filter(
(id) =>
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
)
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
const batches = partitionPreviewIDs(targets)
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
try {
const rows = await libraryAPI.listPreviews(batch, 10)
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
const accepted = rows.filter(
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
)
accepted.forEach((row) => {
fetchedPreviewLimitsRef.current.set(
row.id,
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
)
})
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
for (const row of accepted) {
next[row.id] = {
cards: row.cards ?? [],
total: row.total ?? 0,
}
}
return next
return accepted.length > 0 ? next : prev
})
} catch {
// 单个批次失败不影响其他批次。
} finally {
batch.forEach((id) => {
if (loaded) fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
if (loaded) {
fetchedPreviewLimitsRef.current.set(
id,
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
)
}
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
fetchingPreviewLimitsRef.current.delete(id)
}
})
}
}),
+4 -4
View File
@@ -112,10 +112,10 @@ export function LibrariesContent({
previews: LibraryPreview[]
pinnedIds: string[]
onTogglePin: (libraryId: string) => void
onNeedPreviews?: (ids: string[]) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
@@ -126,7 +126,7 @@ export function LibrariesContent({
useEffect(() => {
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
onNeedPreviews?.(currentTargets)
onNeedPreviews?.(currentTargets, 10)
}, [previews, visibleCount, onNeedPreviews])
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
@@ -196,7 +196,7 @@ export function LibrariesContent({
useEffect(() => {
const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id)
if (ids.length > 0) {
onNeedPreviews?.(ids)
onNeedPreviews?.(ids, 2)
}
}, [pagedPreviews, onNeedPreviews])
+7 -1
View File
@@ -87,8 +87,14 @@ export function LibrarySeriesEpisodes({
>
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, {
maxWidth: 320,
maxHeight: 180,
quality: 76,
})}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
+14 -2
View File
@@ -14,8 +14,14 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.backdrop_url || media.poster_url ? (
<img
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at)}
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, {
maxWidth: 1920,
maxHeight: 1080,
quality: 76,
})}
alt=""
decoding="async"
fetchPriority="low"
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
/>
@@ -36,8 +42,14 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
>
{media.poster_url ? (
<img
src={imageURL(media.poster_url, media.updated_at)}
src={imageURL(media.poster_url, media.updated_at, {
maxWidth: 560,
maxHeight: 840,
quality: 84,
})}
alt={media.title}
decoding="async"
fetchPriority="high"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
+160 -29
View File
@@ -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)驱动。
@@ -1038,6 +1158,9 @@ export function PlayerPage() {
onSubtitleStyleChange={changeSubtitleStyle}
videoRef={ref}
onVideoError={handleVideoError}
playerVolume={playerVolume}
onPlayerVolumeChange={changePlayerVolume}
onPlayerVolumeCommit={commitPlayerVolume}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
@@ -1076,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}
+9
View File
@@ -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 -1
View File
@@ -33,7 +33,7 @@ export function libraryArtworkItems(cards: SeriesCard[] = []): Array<{ src: stri
version: card.rep.updated_at,
}))
.filter((item) => Boolean(item.src))
.slice(0, 4)
.slice(0, 2)
}
export function getLibraryArtworks(
-64
View File
@@ -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',
},
],
}
-2
View File
@@ -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,
]
+2 -3
View File
@@ -148,7 +148,7 @@ function useMediaDetailRefresh({
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
const nextMediaPromise = mediaAPI.get(id)
const favouritesPromise = playbackAPI.listFavourites().catch(() => [])
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
const episodesPromise = mediaAPI
.getEpisodes(id)
.then((r) => r.items ?? [])
@@ -158,8 +158,7 @@ function useMediaDetailRefresh({
setMedia(nextMedia)
setLoading(false)
const favourites = await favouritesPromise
setFavourite(favourites.some((item) => item.id === nextMedia.id))
setFavourite(await favouritePromise)
const episodes = await episodesPromise
setEpisodes(episodes)
+2
View File
@@ -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[]
+10 -6
View File
@@ -33,7 +33,8 @@ import type { Media } from '../types'
export type SeriesCard = {
key: string
rep: Media
linkMedia: Media
linkMedia?: Media
linkLibraryId?: string
count: number
last_added_at?: string
}
@@ -220,12 +221,13 @@ export function seasonLabel(season: number): string {
}
export function isSeriesCard(card: SeriesCard): boolean {
const linkMedia = card.linkMedia ?? card.rep
return (
card.count > 1 ||
isEpisodeLike(card.rep) ||
isEpisodeLike(card.linkMedia) ||
isEpisodeLike(linkMedia) ||
pathLooksEpisodic(card.rep) ||
pathLooksEpisodic(card.linkMedia)
pathLooksEpisodic(linkMedia)
)
}
@@ -474,10 +476,11 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
}
// Repeated movie IDs represent alternate locations/encodes, not
// episodes. Fold the versions but keep the card in movie mode.
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
const currentLinkMedia = g.linkMedia ?? g.rep
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(currentLinkMedia) || pathLooksEpisodic(currentLinkMedia)) {
g.count += 1
}
if (betterSeriesLinkMedia(m, g.linkMedia)) {
if (betterSeriesLinkMedia(m, currentLinkMedia)) {
g.linkMedia = m
}
if (betterSeriesRepresentative(m, g.rep)) {
@@ -534,7 +537,8 @@ function mediaParentLooksLikeCollection(path?: string): boolean {
export function seriesCardLink(card: SeriesCard): string {
if (isSeriesCard(card)) {
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
const targetLibrary = card.linkLibraryId || targetLibraryID(card.linkMedia ?? card.rep)
return `/library/${targetLibrary}?series=${encodeURIComponent(card.key)}`
}
return `/media/${card.rep.id}`
}
+30
View File
@@ -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')
+27
View File
@@ -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
}
}
}