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 ( + + 哈希精准匹配 + + ) + case 'filename': + return ( + + 文件名匹配 + + ) + case 'search': + return ( + + 标题搜索匹配 + + ) + case 'manual': + return ( + + 手动指定 + + ) + default: + return null + } + } + + const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual') + return ( // 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
e.stopPropagation()} - className="absolute right-4 top-16 z-30 w-72 rounded-2xl border border-white/15 bg-black/80 p-4 text-white shadow-2xl backdrop-blur" + className="absolute right-4 top-16 z-30 w-80 rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md" >
- 弹幕设置 + 弹幕设置
+ )} +
+ +
+
{renderMatchBadge(danmakuInfo.matchMode)}
+
+ {danmakuInfo.totalCount > 0 ? `共 ${danmakuInfo.totalCount.toLocaleString()} 条弹幕` : '暂无弹幕内容'} +
+
+
+ ) : candidates.length === 0 ? ( +
+ 未匹配到弹幕,可在下方手动搜索 + {isCustomOrManual && ( + + )} +
+ ) : null} + + )} + {/* 搜索弹幕 */}
-
搜索弹幕(留空 = 按视频名)
-
+
搜索弹幕(留空 = 按视频名自动匹配)
+
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onSearch(draft.trim()) }} - placeholder="输入番剧名…" - className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-sm outline-none placeholder:text-white/35 focus:border-rose-400/60" + placeholder="输入番剧或电影名…" + className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/35 focus:border-rose-400/60" />
- {/* 当前来源 / 候选手动选取 */} - {selectedSource && ( -
- - 当前: {selectedSource} - - -
- )} - + {/* 多番剧命中候选列表 */} {candidates.length > 0 && ( -
+
- 搜到多部番剧,请选择弹幕来源: + 搜到多部番剧,请选择对应集数:
{candidates.map((anime, i) => (