diff --git a/internal/service/danmaku_episode_match_test.go b/internal/service/danmaku_episode_match_test.go index e689969..f5e4862 100644 --- a/internal/service/danmaku_episode_match_test.go +++ b/internal/service/danmaku_episode_match_test.go @@ -43,6 +43,30 @@ func TestDanmakuEpisodeSubtitle(t *testing.T) { } } +// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作 +// 或缺少集标题的平台源抢走。 +func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) { + candidates := []DanmakuAnime{ + {AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{ + {EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}, + }}, + {AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{ + {EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"}, + }}, + {AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{ + {EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"}, + }}, + } + + got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章") + if len(got) != 1 { + t.Fatalf("matched %d anime, want 1: %#v", len(got), got) + } + if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 { + t.Fatalf("picked wrong source: %#v", got) + } +} + // 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本, // 且顺序不可靠。必须靠副标题选中正确的那一集。 func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) { diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index bd3d2dc..b31881e 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -491,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) { require.Contains(t, res.Raw, "弹幕官方兜底") } -// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。 +// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配; +// 应继续走第 2 层按文件名+集数搜索。 func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) { videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A official := danmakuOfficialServer(t, - `{"success":true,"isMatched":false,"matches":[]}`, + `{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`, ``, nil) overrideDanmakuOfficialBase(t, official.URL) @@ -510,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) { require.NoError(t, err) require.True(t, res.Enabled) require.Contains(t, res.Raw, "弹幕A") - // 第 2 层命中:搜索请求按文件名进行。 - require.Contains(t, cfgSrv.lastSearch, "anime=") + require.Equal(t, "filename", res.MatchMode) + require.Equal(t, "测试动画", res.AnimeTitle) + // 第 2 层命中:搜索请求按文件名进行,而不是误用官方第一条模糊候选。 + query, err := url.ParseQuery(cfgSrv.lastSearch) + require.NoError(t, err) + require.Contains(t, query.Get("anime"), "测试动画.第01话") + require.NotContains(t, query.Get("anime"), "91天") +} + +// hash 未精确命中时,若本地已有刮削的剧名、年份、集数和集标题,应先用这些 +// 元数据锁定正确来源,而不是直接进入大量候选的手工选择。 +func TestDanmakuFetchHashMissUsesScrapedMetadata(t *testing.T) { + videoPath, _ := writeDanmakuTestVideo(t, "local-release-name.mkv") + cfgSrv := newDanmakuSourceServerWithSearch(t, + `{"hasMore":false,"animes":[`+ + `{"animeId":1,"animeTitle":"命运石之门 0(2018)【TV动画】from dandan&animeko","episodes":[{"episodeId":120140001,"episodeTitle":"【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}]},`+ + `{"animeId":2,"animeTitle":"命运石之门(2011)【TV动画】from dandan&animeko","episodes":[{"episodeId":25484,"episodeTitle":"【dandan&animeko】 第1话 始与终的序章-Turning Point-"}]},`+ + `{"animeId":3,"animeTitle":"命运石之门(2011)【动漫】from 360","episodes":[{"episodeId":120140002,"episodeTitle":"【qq】 第1集"}]}`+ + `]}`) + official := danmakuOfficialServer(t, + `{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`, + ``, nil) + overrideDanmakuOfficialBase(t, official.URL) + + svc := newDanmakuTestService(t) + ctx := context.Background() + require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL())) + media := model.Media{ + Title: "命运石之门", + Year: 2011, + EpisodeNum: 1, + EpisodeTitle: "起始与终结的序章", + Path: videoPath, + SizeBytes: 32000, + ScrapeStatus: "matched", + } + media.ID = "mScrapedMeta" + require.NoError(t, svc.repo.DB.Create(&media).Error) + + res, err := svc.Fetch(ctx, media.ID, "", "") + require.NoError(t, err) + require.True(t, res.Enabled) + require.Contains(t, res.Raw, "弹幕A") + require.Equal(t, "metadata", res.MatchMode) + require.Equal(t, "命运石之门(2011)【TV动画】from dandan&animeko", res.AnimeTitle) + require.Equal(t, int64(25484), res.EpisodeID) + require.Empty(t, res.Candidates) } // strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。 diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 09e0e7e..9df30a4 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -239,14 +239,16 @@ func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, ena // 1. match: MD5 of the first 16MB of the video (local file read directly, // .strm resolved to a direct link and range-fetched) → /api/v2/match // against the official endpoint, which yields the episode library id. -// 2. search by the playing file's name + episode number. -// 3. current auto-identification (original name → title → file name + episode, +// 2. scraped metadata: title + episode number + episode title, filtered by +// release year and subtitle to disambiguate same-name seasons. +// 3. search by the playing file's name + episode number. +// 4. current auto-identification (original name → title → file name + episode, // single hit used, several hits returned as candidates for the player). -// 4. manual: the player picks from the returned candidates (episodeID / +// 5. manual: the player picks from the returned candidates (episodeID / // keyword override). // // Comments are always fetched from the configured source first (when set) -// and fall back to the official endpoint on failure; identification itself +// and fall back to the official endpoint on failure. Only hash identification // always goes to the official endpoint. // // When danmaku is disabled the result carries Enabled=false so the player can @@ -343,7 +345,24 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, } } - // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 + // 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。 + // 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。 + if target == "" && !manualKeyword && media != nil { + if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok { + configuredID := firstDanmakuEpisodeID(matched) + target = strconv.FormatInt(configuredID, 10) + targetBase = base + res.AnimeTitle = matched[0].AnimeTitle + res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle + res.EpisodeID = configuredID + res.MatchMode = "metadata" + if len(matched) > 1 { + res.Alternatives = matched + } + } + } + + // 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。 if target == "" && !manualKeyword && media != nil && media.Path != "" { if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil && @@ -358,7 +377,7 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, } } - // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, + // 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, // 多结果返回候选列表交给播放器(歧义处理)。 if target == "" { candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode) @@ -947,7 +966,10 @@ type danmakuMatch struct { // matchOfficial identifies the video via POST /api/v2/match on the official // endpoint (always official, signed with the app credentials). Returns the -// candidate list; empty means nothing matched. +// candidate list only when the upstream explicitly reports a confident match; +// empty means nothing matched. The API may return fuzzy suggestions together +// with isMatched=false; those are not safe to auto-select because their first +// item can belong to an unrelated anime. // // fileName must be URL-escaped: the official API rejects raw non-ASCII file // names with errorCode 2 (verified against the live API — QueryEscape's @@ -997,13 +1019,14 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode) } var out struct { - Success bool `json:"success"` - Matches []danmakuMatch `json:"matches"` + Success bool `json:"success"` + IsMatched bool `json:"isMatched"` + Matches []danmakuMatch `json:"matches"` } if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err) } - if !out.Success { + if !out.Success || !out.IsMatched { return nil, nil } return out.Matches, nil @@ -1061,6 +1084,9 @@ func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configu // 不一(有的只有集数、有的带副标题),正则只认集数标记本身。 var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`) +// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。 +var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`) + // danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。 func danmakuEpisodeNumber(title string) string { m := danmakuEpisodeNumberRE.FindStringSubmatch(title) @@ -1091,6 +1117,16 @@ func normalizeDanmakuText(s string) []rune { return out } +// danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。 +func danmakuTitleYear(title string) int { + m := danmakuTitleYearRE.FindString(title) + if len(m) != 6 { + return 0 + } + year, _ := strconv.Atoi(m[1:5]) + return year +} + // danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。 // // 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字 @@ -1114,6 +1150,9 @@ func danmakuSubtitleMatches(candidateTitle, subtitle string) bool { if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) { return true } + if danmakuRuneSimilarity(a, b) >= 0.7 { + return true + } n := len(a) if len(b) < n { n = len(b) @@ -1124,6 +1163,34 @@ func danmakuSubtitleMatches(candidateTitle, subtitle string) bool { return string(a[:n]) == string(b[:n]) } +// danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。 +// 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离; +// 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。 +func danmakuRuneSimilarity(a, b []rune) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + prev := make([]int, len(b)+1) + for _, ra := range a { + cur := make([]int, len(b)+1) + for j, rb := range b { + if ra == rb { + cur[j+1] = prev[j] + 1 + } else if cur[j] > prev[j+1] { + cur[j+1] = cur[j] + } else { + cur[j+1] = prev[j+1] + } + } + prev = cur + } + minLen := len(a) + if len(b) < minLen { + minLen = len(b) + } + return float64(prev[len(b)]) / float64(minLen) +} + // lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新 // 定位同一集,返回配置源里所有指向该集的结果。 // @@ -1153,6 +1220,84 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure return matched, true } +// lookupScrapedEpisodes uses already-scraped media metadata to locate the +// correct library when hash identification missed. Unlike the generic title +// search, this path requires an explicit episode-title match and, when known, +// a matching release year. That prevents Steins;Gate (2011) from being +// confused with Steins;Gate 0 (2018) or other same-name entries. +func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) { + title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media) + if !ok { + return nil, "", false + } + candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum) + if err != nil { + s.log.Debug("danmaku scraped metadata lookup failed", + zap.String("title", title), + zap.String("episode", episodeNum), + zap.Error(err)) + return nil, "", false + } + matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle) + if len(matched) == 0 { + return nil, "", false + } + return matched, base, true +} + +// scrapedDanmakuMetadata returns the query fields only when the media has +// enough scraped information to identify a specific episode. +func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) { + if media == nil { + return "", "", "", false + } + title = strings.TrimSpace(media.Title) + if title == "" { + title = strings.TrimSpace(media.OriginalName) + } + episodeTitle = strings.TrimSpace(media.EpisodeTitle) + if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" { + return "", "", "", false + } + return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true +} + +// matchScrapedDanmakuEpisodes filters title search hits by release year and +// episode subtitle. The returned list keeps the source grouping so multiple +// libraries for the same episode remain switchable in the player. +func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime { + out := make([]DanmakuAnime, 0, len(candidates)) + for _, anime := range candidates { + if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) { + continue + } + hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle) + if len(hits) == 0 { + continue + } + anime.Episodes = hits + out = append(out, anime) + } + return out +} + +// danmakuAnimeTitleMatches checks the scraped title and, when present, the +// candidate's year. A candidate with a conflicting explicit year is rejected +// even if its title contains the scraped title. +func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool { + candidate := string(normalizeDanmakuText(animeTitle)) + target := string(normalizeDanmakuText(mediaTitle)) + if candidate == "" || target == "" || !strings.Contains(candidate, target) { + return false + } + if year > 0 { + if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year { + return false + } + } + return true +} + // firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。 func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 { for _, anime := range matched { @@ -1184,20 +1329,9 @@ func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle st // 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个 // 列表作为可切换来源展示。 func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime { - subtitle := danmakuEpisodeSubtitle(episodeTitle) out := make([]DanmakuAnime, 0, len(candidates)) for _, anime := range candidates { - hits := make([]DanmakuEpisode, 0, len(anime.Episodes)) - for _, ep := range anime.Episodes { - if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum { - continue - } - // 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。 - if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) { - continue - } - hits = append(hits, ep) - } + hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle) if len(hits) == 0 { continue } @@ -1206,3 +1340,25 @@ func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle st } return out } + +// matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。 +func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode { + subtitle := danmakuEpisodeSubtitle(episodeTitle) + // 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」), + // 不带「第1话」前缀。此时把它整体作为副标题参与比对。 + if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" { + subtitle = strings.TrimSpace(episodeTitle) + } + hits := make([]DanmakuEpisode, 0, len(episodes)) + for _, ep := range episodes { + if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum { + continue + } + // 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。 + if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) { + continue + } + hits = append(hits, ep) + } + return hits +} diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index 3d4c32a..a8f941d 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -41,14 +41,14 @@ export interface DanmakuFetchResult { anime_title?: string episode_title?: string episode_id?: number - match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string + match_mode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string } export interface DanmakuLoadedInfo { animeTitle?: string episodeTitle?: string episodeId?: number | string - matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string + matchMode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string totalCount: number sourceType?: 'auto' | 'xml' | 'json' /** Number of sources merged into the loaded comments (0 = not merged). */ @@ -97,4 +97,4 @@ export const danmakuAPI = { merge_sources: settings.mergeSources, }) .then((r) => r.data), -} \ No newline at end of file +} diff --git a/web/src/components/PlayerDanmakuPanel.tsx b/web/src/components/PlayerDanmakuPanel.tsx index cbd7a31..37c729d 100644 --- a/web/src/components/PlayerDanmakuPanel.tsx +++ b/web/src/components/PlayerDanmakuPanel.tsx @@ -109,6 +109,12 @@ export function PlayerDanmakuPanel({ 文件名匹配 ) + case 'metadata': + return ( + + 刮削信息匹配 + + ) case 'search': return ( @@ -469,4 +475,4 @@ function EpisodeRow({ {episode.episodeTitle} ) -} \ No newline at end of file +}