From 8f64f961ac1563b8f46c029358b5daec447bdd55 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:02:24 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=BC=B9=E5=B9=95=E6=8A=93?= =?UTF-8?q?=E5=8F=96=E7=BB=93=E6=9E=9C=E5=B9=B6=E5=90=88=E5=B9=B6=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- internal/service/danmaku_match_test.go | 35 +++++ internal/service/danmaku_service.go | 168 ++++++++++++++++++++++- internal/service/danmaku_service_test.go | 78 +++++++++++ 4 files changed, 277 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 0fe802c..8a57a16 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( go.uber.org/zap v1.27.0 golang.org/x/crypto v0.49.0 golang.org/x/image v0.37.0 + golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 @@ -88,7 +89,6 @@ require ( golang.org/x/arch v0.25.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index b31881e..5afca8b 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -824,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) { require.Equal(t, "降级搜索番剧", res.AnimeTitle) require.Contains(t, res.Raw, "降级搜索弹幕") } + +// 即使刮削元数据完整,也应先走准确率最高的 hash 层。 +func TestDanmakuFetchPrefersHashOverCompleteMetadata(t *testing.T) { + videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") + var seen string + official := danmakuOfficialServer(t, + `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"官方测试动画","episodeTitle":"第1话"}]}`, + `hash命中`, + &seen) + overrideDanmakuOfficialBase(t, official.URL) + + source := newDanmakuSourceServerWithSearch(t, + `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`) + + svc := newDanmakuTestService(t) + ctx := context.Background() + require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL())) + + m := model.Media{ + Title: "测试动画", + Path: videoPath, + SizeBytes: 123, + EpisodeNum: 1, + EpisodeTitle: "起始与终结的序章", + Year: 2011, + } + m.ID = "hash-first" + require.NoError(t, svc.repo.DB.Create(&m).Error) + + res, err := svc.Fetch(ctx, "hash-first", "", "") + require.NoError(t, err) + require.Equal(t, "hash", res.MatchMode) + require.NotEmpty(t, res.Raw) + require.Contains(t, seen, wantHash) +} diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 9df30a4..959502c 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -20,6 +20,8 @@ import ( "time" "unicode" + "golang.org/x/sync/singleflight" + "go.uber.org/zap" "github.com/truewhile/MeBox/internal/model" @@ -136,6 +138,12 @@ type DanmakuService struct { hashCacheMu sync.Mutex hashCache map[string]string // stamp → 16MB-prefix MD5 + + // resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走 + // 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。 + resultCacheMu sync.Mutex + resultCache map[string]danmakuResultCacheEntry + fetchGroup singleflight.Group } func danmakuHTTPClient() *http.Client { @@ -147,10 +155,11 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ log = zap.NewNop() } return &DanmakuService{ - log: log, - repo: repo, - client: danmakuHTTPClient(), - hashCache: make(map[string]string), + log: log, + repo: repo, + client: danmakuHTTPClient(), + hashCache: make(map[string]string), + resultCache: make(map[string]danmakuResultCacheEntry), } } @@ -170,6 +179,121 @@ func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolv } } +// 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行 +// 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化 +// 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。 +const ( + danmakuResultCacheTTL = 6 * time.Hour + danmakuResultCacheMaxEntries = 64 + // 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。 + danmakuResultCacheCandidateTTL = 10 * time.Minute + // 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。 + danmakuResultCacheEmptyTTL = time.Minute +) + +type danmakuResultCacheEntry struct { + result *DanmakuFetchResult + expiresAt time.Time + storedAt time.Time +} + +func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string { + return strings.Join([]string{ + strings.TrimSpace(source), + mediaID, + strings.TrimSpace(keyword), + strings.TrimSpace(episodeID), + strconv.FormatBool(merge), + }, "\x00") +} + +func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) { + if s == nil || key == "" { + return nil, false + } + now := time.Now() + s.resultCacheMu.Lock() + defer s.resultCacheMu.Unlock() + entry, ok := s.resultCache[key] + if !ok { + return nil, false + } + if now.After(entry.expiresAt) { + delete(s.resultCache, key) + return nil, false + } + return cloneDanmakuFetchResult(entry.result), true +} + +func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) { + if s == nil || key == "" || result == nil { + return + } + now := time.Now() + s.resultCacheMu.Lock() + defer s.resultCacheMu.Unlock() + if s.resultCache == nil { + s.resultCache = make(map[string]danmakuResultCacheEntry) + } + ttl := danmakuResultCacheTTLFor(result) + if ttl <= 0 { + return + } + if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries { + oldestKey := "" + var oldest time.Time + for k, entry := range s.resultCache { + if oldestKey == "" || entry.storedAt.Before(oldest) { + oldestKey, oldest = k, entry.storedAt + } + } + delete(s.resultCache, oldestKey) + } + s.resultCache[key] = danmakuResultCacheEntry{ + result: cloneDanmakuFetchResult(result), + expiresAt: now.Add(ttl), + storedAt: now, + } +} + +// danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得 +// 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。 +func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration { + if result == nil { + return 0 + } + if strings.TrimSpace(result.Raw) != "" { + return danmakuResultCacheTTL + } + if len(result.Candidates) > 0 { + return danmakuResultCacheCandidateTTL + } + return danmakuResultCacheEmptyTTL +} + +// cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。 +func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult { + if in == nil { + return nil + } + out := *in + out.Candidates = cloneDanmakuAnimeList(in.Candidates) + out.Alternatives = cloneDanmakuAnimeList(in.Alternatives) + return &out +} + +func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime { + if in == nil { + return nil + } + out := make([]DanmakuAnime, len(in)) + for i, anime := range in { + out[i] = anime + out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...) + } + return out +} + // Config reads danmaku settings from the runtime settings table. func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { cfg := DanmakuRenderConfig{ @@ -257,8 +381,42 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{}) } -// FetchWithOptions 是 Fetch 的带偏好版本。 +// FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 + +// 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。 func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { + if s == nil { + return nil, errors.New("danmaku service unavailable") + } + cfg := s.Config(ctx) + if !cfg.Enabled { + return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil + } + key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources) + if cached, ok := s.resultCacheGet(key); ok { + return cached, nil + } + value, err, _ := s.fetchGroup.Do(key, func() (any, error) { + // 等待期间可能已有同一 key 的请求写入缓存。 + if cached, ok := s.resultCacheGet(key); ok { + return cached, nil + } + res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts) + if err != nil { + // 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。 + return res, err + } + s.resultCachePut(key, res) + return res, nil + }) + res, _ := value.(*DanmakuFetchResult) + if err != nil { + return cloneDanmakuFetchResult(res), err + } + return cloneDanmakuFetchResult(res), nil +} + +// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。 +func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"} if !res.Enabled { return res, nil diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index 2d7a390..48f48a8 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "sync" + "sync/atomic" "testing" "github.com/glebarez/sqlite" @@ -326,3 +328,79 @@ func TestDanmakuFetchDetectsJSONSource(t *testing.T) { require.Equal(t, "xml", res2.SourceType) require.Contains(t, res2.Raw, "弹幕A") } + +// 同一集在缓存 TTL 内重复请求时不应再访问上游(搜索和评论都只发一次)。 +func TestDanmakuFetchCachesRepeatedRequests(t *testing.T) { + var searchCalls, commentCalls int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&searchCalls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&commentCalls, 1) + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `缓存测试`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newDanmakuTestService(t) + ctx := context.Background() + require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL)) + seedDanmakuMedia(t, svc, "cache-media", "测试动画", "", 1) + + first, err := svc.Fetch(ctx, "cache-media", "", "") + require.NoError(t, err) + require.NotEmpty(t, first.Raw) + + second, err := svc.Fetch(ctx, "cache-media", "", "") + require.NoError(t, err) + require.Equal(t, first.Raw, second.Raw) + require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls)) + require.EqualValues(t, 1, atomic.LoadInt32(&commentCalls)) +} + +// 并发的同一集请求应被 singleflight 合并,上游只被访问一次。 +func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) { + var searchCalls int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&searchCalls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `并发测试`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newDanmakuTestService(t) + ctx := context.Background() + require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL)) + seedDanmakuMedia(t, svc, "concurrent-media", "测试动画", "", 1) + + const workers = 8 + start := make(chan struct{}) + errs := make(chan error, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := svc.Fetch(ctx, "concurrent-media", "", "") + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls)) +}