From efa64051ea8f618bc9d18b541cced539c8d9f690 Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Tue, 25 Aug 2026 16:39:47 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
优化
---
internal/service/danmaku_match_test.go | 4 +
internal/service/danmaku_service.go | 152 +++++++++++---------
internal/service/danmaku_service_test.go | 6 +
web/src/api/danmaku.ts | 13 ++
web/src/components/DanmakuStage.tsx | 23 +++-
web/src/components/PlayerDanmakuPanel.tsx | 161 ++++++++++++++++------
web/src/pages/PlayerPage.tsx | 14 +-
web/src/pages/PlayerVideoStage.tsx | 4 +-
8 files changed, 260 insertions(+), 117 deletions(-)
diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go
index b7c7074..35065b5 100644
--- a/internal/service/danmaku_match_test.go
+++ b/internal/service/danmaku_match_test.go
@@ -92,6 +92,10 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
require.Equal(t, "xml", res.SourceType)
require.Contains(t, res.Raw, "弹幕Hash命中")
require.Empty(t, res.Candidates)
+ require.Equal(t, "测试动画", res.AnimeTitle)
+ require.Equal(t, "第1话", res.EpisodeTitle)
+ require.Equal(t, int64(25484), res.EpisodeID)
+ require.Equal(t, "hash", res.MatchMode)
// match 请求体:文件名去扩展名并 URL 转义(官方接口要求,实测验证)、
// hash、大小、matchMode 齐全。
diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go
index 461be0d..1735bee 100644
--- a/internal/service/danmaku_service.go
+++ b/internal/service/danmaku_service.go
@@ -67,11 +67,17 @@ type DanmakuRenderConfig struct {
//
// Candidates is non-nil when multiple anime matched the search and the player
// must ask the user which one to use (disambiguation); Raw is empty then.
+// AnimeTitle, EpisodeTitle, EpisodeID and MatchMode provide matched danmaku
+// metadata so the player UI can display which episode was loaded.
type DanmakuFetchResult struct {
DanmakuRenderConfig
- SourceType string `json:"source_type"`
- Raw string `json:"raw,omitempty"`
- Candidates []DanmakuAnime `json:"candidates,omitempty"`
+ SourceType string `json:"source_type"`
+ Raw string `json:"raw,omitempty"`
+ Candidates []DanmakuAnime `json:"candidates,omitempty"`
+ AnimeTitle string `json:"anime_title,omitempty"`
+ EpisodeTitle string `json:"episode_title,omitempty"`
+ EpisodeID int64 `json:"episode_id,omitempty"`
+ MatchMode string `json:"match_mode,omitempty"`
}
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
@@ -184,74 +190,90 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase
- // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
- if target := strings.TrimSpace(episodeID); target != "" {
- raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
- if err != nil {
- s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
- return res, err
- }
- res.Raw, res.SourceType = raw, st
- return res, nil
- }
-
- term, media, err := s.searchTerms(ctx, mediaID)
- if err != nil {
- return res, err
- }
- manualKeyword := strings.TrimSpace(keyword) != ""
- if kw := strings.TrimSpace(keyword); kw != "" {
- term.name = kw
- }
- if strings.TrimSpace(term.name) == "" {
- return res, nil
- }
-
- target := ""
-
- // 1) hash 识别:始终走官方 /api/v2/match。
- if media != nil && media.Path != "" {
- if hash, ok := s.mediaHash(ctx, media); ok {
- fileSize := media.SizeBytes
- if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
- fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
- }
- matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
+ // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
+ if target := strings.TrimSpace(episodeID); target != "" {
+ raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
- s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
- } else if len(matches) > 0 {
- target = fmt.Sprintf("%d", matches[0].EpisodeID)
+ s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
+ return res, err
}
- }
- }
-
- // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
- if target == "" && !manualKeyword && media != nil && media.Path != "" {
- if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
- if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
- len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
- target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
+ res.Raw, res.SourceType = raw, st
+ if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
+ res.EpisodeID = id
}
- }
- }
-
- // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
- // 多结果返回候选列表交给播放器(歧义处理)。
- if target == "" {
- candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
- if err != nil {
- s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
- return res, err
- }
- if len(candidates) != 1 {
- res.Candidates = candidates
+ res.MatchMode = "manual"
return res, nil
}
- if len(candidates[0].Episodes) == 0 {
- return res, errors.New("no danmaku library found for this video")
+
+ term, media, err := s.searchTerms(ctx, mediaID)
+ if err != nil {
+ return res, err
+ }
+ manualKeyword := strings.TrimSpace(keyword) != ""
+ if kw := strings.TrimSpace(keyword); kw != "" {
+ term.name = kw
+ }
+ if strings.TrimSpace(term.name) == "" {
+ return res, nil
+ }
+
+ target := ""
+
+ // 1) hash 识别:始终走官方 /api/v2/match。
+ if media != nil && media.Path != "" {
+ if hash, ok := s.mediaHash(ctx, media); ok {
+ fileSize := media.SizeBytes
+ if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
+ fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
+ }
+ matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
+ if err != nil {
+ s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
+ } else if len(matches) > 0 {
+ target = fmt.Sprintf("%d", matches[0].EpisodeID)
+ res.AnimeTitle = matches[0].AnimeTitle
+ res.EpisodeTitle = matches[0].EpisodeTitle
+ res.EpisodeID = matches[0].EpisodeID
+ res.MatchMode = "hash"
+ }
+ }
+ }
+
+ // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
+ if target == "" && !manualKeyword && media != nil && media.Path != "" {
+ if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
+ if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
+ len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
+ target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
+ res.AnimeTitle = candidates[0].AnimeTitle
+ res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
+ res.EpisodeID = candidates[0].Episodes[0].EpisodeID
+ res.MatchMode = "filename"
+ }
+ }
+ }
+
+ // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
+ // 多结果返回候选列表交给播放器(歧义处理)。
+ if target == "" {
+ candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
+ if err != nil {
+ s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
+ return res, err
+ }
+ if len(candidates) != 1 {
+ res.Candidates = candidates
+ return res, nil
+ }
+ if len(candidates[0].Episodes) == 0 {
+ return res, errors.New("no danmaku library found for this video")
+ }
+ target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
+ res.AnimeTitle = candidates[0].AnimeTitle
+ res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
+ res.EpisodeID = candidates[0].Episodes[0].EpisodeID
+ res.MatchMode = "search"
}
- target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
- }
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go
index c3b3aa3..608b674 100644
--- a/internal/service/danmaku_service_test.go
+++ b/internal/service/danmaku_service_test.go
@@ -142,6 +142,10 @@ func TestDanmakuFetchWithDandanplaySource(t *testing.T) {
require.Equal(t, "xml", res.SourceType)
require.Contains(t, res.Raw, "弹幕A")
require.Contains(t, res.Raw, `p="0.5,1,16777215,user1"`)
+ require.Equal(t, "测试动画", res.AnimeTitle)
+ require.Equal(t, "第1话", res.EpisodeTitle)
+ require.Equal(t, int64(25484), res.EpisodeID)
+ require.Equal(t, "search", res.MatchMode)
}
func TestDanmakuFetchUsesOriginalNameForSearch(t *testing.T) {
@@ -262,6 +266,8 @@ func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) {
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "显式指定弹幕")
require.Empty(t, res.Candidates)
+ require.Equal(t, int64(99999), res.EpisodeID)
+ require.Equal(t, "manual", res.MatchMode)
}
func TestDetectDanmakuSourceType(t *testing.T) {
cases := []struct {
diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts
index a022acf..69a8648 100644
--- a/web/src/api/danmaku.ts
+++ b/web/src/api/danmaku.ts
@@ -28,6 +28,19 @@ export interface DanmakuFetchResult {
area: string
raw?: string
candidates?: DanmakuAnime[]
+ anime_title?: string
+ episode_title?: string
+ episode_id?: number
+ match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
+}
+
+export interface DanmakuLoadedInfo {
+ animeTitle?: string
+ episodeTitle?: string
+ episodeId?: number | string
+ matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
+ totalCount: number
+ sourceType?: 'auto' | 'xml' | 'json'
}
export type DanmakuFetchOptions = {
diff --git a/web/src/components/DanmakuStage.tsx b/web/src/components/DanmakuStage.tsx
index 142fb36..3b8d197 100644
--- a/web/src/components/DanmakuStage.tsx
+++ b/web/src/components/DanmakuStage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { create, type Manager, type ManagerPlugin } from 'danmu'
-import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
+import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import type { Media } from '../types'
import { parseDanmaku, type Comment } from '../utils/parseDanmaku'
@@ -28,8 +28,8 @@ type DanmakuStageProps = {
search?: string | null
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
episodeId?: number | string | null
- /** Called after each fetch attempt (success or error) finishes. */
- onLoaded?: () => void
+ /** Called after each fetch attempt (success or error) finishes with metadata. */
+ onLoaded?: (info: DanmakuLoadedInfo | null) => void
/** Called when multiple anime matched and the user must pick one. */
onCandidates?: (candidates: DanmakuAnime[]) => void
}
@@ -127,6 +127,7 @@ export function DanmakuStage({
applyLiveSettings()
const loadDanmaku = async () => {
+ let loadedInfo: DanmakuLoadedInfo | null = null
try {
const res = await danmakuAPI.fetch(media.id, {
kw: search ?? undefined,
@@ -144,14 +145,28 @@ export function DanmakuStage({
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
.sort((a, b) => a.time - b.time)
nextIndex = 0
+ loadedInfo = {
+ animeTitle: res.anime_title,
+ episodeTitle: res.episode_title,
+ episodeId: res.episode_id ?? (episodeId ? Number(episodeId) || episodeId : undefined),
+ matchMode: res.match_mode,
+ totalCount: comments.length,
+ sourceType: res.source_type,
+ }
} else {
comments = []
+ loadedInfo = {
+ totalCount: 0,
+ }
}
} catch {
// 拉取失败时静默关闭弹幕,不打断播放。
comments = []
+ loadedInfo = {
+ totalCount: 0,
+ }
} finally {
- if (!disposed) onLoaded?.()
+ if (!disposed) onLoaded?.(loadedInfo)
}
}
diff --git a/web/src/components/PlayerDanmakuPanel.tsx b/web/src/components/PlayerDanmakuPanel.tsx
index 24e0b29..076ec97 100644
--- a/web/src/components/PlayerDanmakuPanel.tsx
+++ b/web/src/components/PlayerDanmakuPanel.tsx
@@ -1,18 +1,12 @@
import { useEffect, useState } from 'react'
-import { Check, ChevronRight, Loader2, MessageSquareText, Search, X } from 'lucide-react'
+import { Check, ChevronRight, Film, Hash, Loader2, MessageSquareText, RefreshCw, Search, Sparkles, Tag, X } from 'lucide-react'
-import type { DanmakuAnime, DanmakuEpisode } from '../api/danmaku'
+import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
-// PlayerDanmakuPanel — the on-player danmaku control panel. It toggles
-// loading, lets the user re-search by a custom keyword, and adjusts the
-// renderer knobs (display area / opacity / font size) live. Values are
-// controlled by PlayerPage so the panel is purely presentational.
-//
-// The knobs change immediately on drag; a "re-search" only fires when the
-// user clicks the search button (or presses Enter) so sliders don't trigger
-// network requests. When the backend returns multiple anime candidates
-// (disambiguation), the panel shows the picker so the user can choose the
-// right danmaku library by hand — mirroring danmaku-anywhere's selector.
+// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
+// the matched danmaku details (anime title, episode title, comment count,
+// match mode), toggles loading, lets the user re-search by a custom keyword,
+// and adjusts the renderer knobs (display area / opacity / font size) live.
type PlayerDanmakuPanelProps = {
open: boolean
onClose: () => void
@@ -30,7 +24,9 @@ type PlayerDanmakuPanelProps = {
/** Multiple anime matched — user must pick one. */
candidates: DanmakuAnime[]
/** Human-readable label of the currently selected library. */
- selectedSource: string
+ selectedSource?: string
+ /** Loaded danmaku metadata (title, episode, count, match mode). */
+ danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
onResetAuto: () => void
}
@@ -51,6 +47,7 @@ export function PlayerDanmakuPanel({
onFontSizeChange,
candidates,
selectedSource,
+ danmakuInfo,
onSelectEpisode,
onResetAuto,
}: PlayerDanmakuPanelProps) {
@@ -80,15 +77,49 @@ export function PlayerDanmakuPanel({
})
}
+ // 匹配模式标签显示辅助
+ const renderMatchBadge = (mode?: string) => {
+ switch (mode) {
+ case 'hash':
+ return (
+
+