mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
优先使用刮削元数据匹配弹幕剧集
This commit is contained in:
@@ -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 给出的剧名+集数在配置源里会命中多季/多版本,
|
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
|
||||||
// 且顺序不可靠。必须靠副标题选中正确的那一集。
|
// 且顺序不可靠。必须靠副标题选中正确的那一集。
|
||||||
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
|
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
|
||||||
|
|||||||
@@ -491,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
|||||||
require.Contains(t, res.Raw, "弹幕官方兜底")
|
require.Contains(t, res.Raw, "弹幕官方兜底")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
|
// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配;
|
||||||
|
// 应继续走第 2 层按文件名+集数搜索。
|
||||||
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||||
|
|
||||||
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
||||||
official := danmakuOfficialServer(t,
|
official := danmakuOfficialServer(t,
|
||||||
`{"success":true,"isMatched":false,"matches":[]}`,
|
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||||
`<i></i>`, nil)
|
`<i></i>`, nil)
|
||||||
overrideDanmakuOfficialBase(t, official.URL)
|
overrideDanmakuOfficialBase(t, official.URL)
|
||||||
|
|
||||||
@@ -510,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, res.Enabled)
|
require.True(t, res.Enabled)
|
||||||
require.Contains(t, res.Raw, "弹幕A")
|
require.Contains(t, res.Raw, "弹幕A")
|
||||||
// 第 2 层命中:搜索请求按文件名进行。
|
require.Equal(t, "filename", res.MatchMode)
|
||||||
require.Contains(t, cfgSrv.lastSearch, "anime=")
|
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话 杀人之夜"}]}`,
|
||||||
|
`<i></i>`, 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 → 拉弹幕。
|
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||||
|
|||||||
@@ -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,
|
// 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
|
// .strm resolved to a direct link and range-fetched) → /api/v2/match
|
||||||
// against the official endpoint, which yields the episode library id.
|
// against the official endpoint, which yields the episode library id.
|
||||||
// 2. search by the playing file's name + episode number.
|
// 2. scraped metadata: title + episode number + episode title, filtered by
|
||||||
// 3. current auto-identification (original name → title → file name + episode,
|
// 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).
|
// 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).
|
// keyword override).
|
||||||
//
|
//
|
||||||
// Comments are always fetched from the configured source first (when set)
|
// 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.
|
// always goes to the official endpoint.
|
||||||
//
|
//
|
||||||
// When danmaku is disabled the result carries Enabled=false so the player can
|
// 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 target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||||
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
|
||||||
if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil &&
|
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 == "" {
|
if target == "" {
|
||||||
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode)
|
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
|
// matchOfficial identifies the video via POST /api/v2/match on the official
|
||||||
// endpoint (always official, signed with the app credentials). Returns the
|
// 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
|
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
|
||||||
// names with errorCode 2 (verified against the live API — QueryEscape's
|
// 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)
|
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Matches []danmakuMatch `json:"matches"`
|
IsMatched bool `json:"isMatched"`
|
||||||
|
Matches []danmakuMatch `json:"matches"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
|
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
|
||||||
}
|
}
|
||||||
if !out.Success {
|
if !out.Success || !out.IsMatched {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return out.Matches, 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*[话話集]`)
|
var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`)
|
||||||
|
|
||||||
|
// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。
|
||||||
|
var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`)
|
||||||
|
|
||||||
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
|
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
|
||||||
func danmakuEpisodeNumber(title string) string {
|
func danmakuEpisodeNumber(title string) string {
|
||||||
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
|
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
|
||||||
@@ -1091,6 +1117,16 @@ func normalizeDanmakuText(s string) []rune {
|
|||||||
return out
|
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 判断两个源的副标题是否指向同一集。
|
// danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。
|
||||||
//
|
//
|
||||||
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
|
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
|
||||||
@@ -1114,6 +1150,9 @@ func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
|
|||||||
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
|
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if danmakuRuneSimilarity(a, b) >= 0.7 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
n := len(a)
|
n := len(a)
|
||||||
if len(b) < n {
|
if len(b) < n {
|
||||||
n = len(b)
|
n = len(b)
|
||||||
@@ -1124,6 +1163,34 @@ func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
|
|||||||
return string(a[:n]) == string(b[:n])
|
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 给出的「剧名 + 集数」在配置源里重新
|
// lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新
|
||||||
// 定位同一集,返回配置源里所有指向该集的结果。
|
// 定位同一集,返回配置源里所有指向该集的结果。
|
||||||
//
|
//
|
||||||
@@ -1153,6 +1220,84 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure
|
|||||||
return matched, true
|
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,作为自动选中的库。
|
// firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。
|
||||||
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
|
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
|
||||||
for _, anime := range matched {
|
for _, anime := range matched {
|
||||||
@@ -1184,20 +1329,9 @@ func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle st
|
|||||||
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
|
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
|
||||||
// 列表作为可切换来源展示。
|
// 列表作为可切换来源展示。
|
||||||
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
|
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
|
||||||
subtitle := danmakuEpisodeSubtitle(episodeTitle)
|
|
||||||
out := make([]DanmakuAnime, 0, len(candidates))
|
out := make([]DanmakuAnime, 0, len(candidates))
|
||||||
for _, anime := range candidates {
|
for _, anime := range candidates {
|
||||||
hits := make([]DanmakuEpisode, 0, len(anime.Episodes))
|
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if len(hits) == 0 {
|
if len(hits) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1206,3 +1340,25 @@ func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle st
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export interface DanmakuFetchResult {
|
|||||||
anime_title?: string
|
anime_title?: string
|
||||||
episode_title?: string
|
episode_title?: string
|
||||||
episode_id?: number
|
episode_id?: number
|
||||||
match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
match_mode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DanmakuLoadedInfo {
|
export interface DanmakuLoadedInfo {
|
||||||
animeTitle?: string
|
animeTitle?: string
|
||||||
episodeTitle?: string
|
episodeTitle?: string
|
||||||
episodeId?: number | string
|
episodeId?: number | string
|
||||||
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
matchMode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||||
totalCount: number
|
totalCount: number
|
||||||
sourceType?: 'auto' | 'xml' | 'json'
|
sourceType?: 'auto' | 'xml' | 'json'
|
||||||
/** Number of sources merged into the loaded comments (0 = not merged). */
|
/** Number of sources merged into the loaded comments (0 = not merged). */
|
||||||
@@ -97,4 +97,4 @@ export const danmakuAPI = {
|
|||||||
merge_sources: settings.mergeSources,
|
merge_sources: settings.mergeSources,
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ export function PlayerDanmakuPanel({
|
|||||||
<Tag size={10} /> 文件名匹配
|
<Tag size={10} /> 文件名匹配
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
case 'metadata':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded border border-cyan-500/30 bg-cyan-500/15 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300">
|
||||||
|
<Search size={10} /> 刮削信息匹配
|
||||||
|
</span>
|
||||||
|
)
|
||||||
case 'search':
|
case 'search':
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
|
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
|
||||||
@@ -469,4 +475,4 @@ function EpisodeRow({
|
|||||||
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user