mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e71d9d61d1 |
@@ -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) {
|
||||
|
||||
@@ -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话 杀人之夜"}]}`,
|
||||
`<i></i>`, 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话 杀人之夜"}]}`,
|
||||
`<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 → 拉弹幕。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ export function PlayerDanmakuPanel({
|
||||
<Tag size={10} /> 文件名匹配
|
||||
</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':
|
||||
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">
|
||||
@@ -469,4 +475,4 @@ function EpisodeRow({
|
||||
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user