mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 08:16:37 +08:00
优化弹幕识别逻辑
优化弹幕识别逻辑
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
@@ -27,6 +33,11 @@ const (
|
||||
DanmakuOpacityKey = "danmaku.opacity"
|
||||
DanmakuFontSizeKey = "danmaku.font_size"
|
||||
DanmakuAreaKey = "danmaku.area"
|
||||
// DanmakuAppIDKey / DanmakuAppKeyKey hold the optional dandanplay
|
||||
// DevCenter application credentials. When both are set they override the
|
||||
// built-in fallback pair (see danmaku_credentials.go).
|
||||
DanmakuAppIDKey = "danmaku.app_id"
|
||||
DanmakuAppKeyKey = "danmaku.app_key"
|
||||
)
|
||||
|
||||
// DanmakuDefaultSource is the official dandanplay endpoint used when the
|
||||
@@ -34,6 +45,11 @@ const (
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||
// comment/search fallback always go, regardless of the configured source.
|
||||
// A package var (not a const) so tests can point it at a local server.
|
||||
var danmakuOfficialBase = DanmakuDefaultSource
|
||||
|
||||
// DanmakuRenderConfig carries the renderer knobs to the web player.
|
||||
type DanmakuRenderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -73,12 +89,21 @@ type DanmakuEpisode struct {
|
||||
}
|
||||
|
||||
// DanmakuService fetches danmaku for a media item through the dandanplay
|
||||
// protocol: search for an episode id by the video's name, then fetch the
|
||||
// comment library XML. The React player parses and renders it.
|
||||
// protocol: match by 16MB-prefix hash, then search for an episode id by the
|
||||
// video's name, then fetch the comment library XML. The React player parses
|
||||
// and renders it.
|
||||
type DanmakuService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
client *http.Client
|
||||
|
||||
// strmResolve resolves a .strm play indirection into a fetchable target
|
||||
// (local path / redirect URL / proxied link). Wired by the builder to
|
||||
// StrmService.ResolvePlay; nil means strm sources are skipped.
|
||||
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
|
||||
|
||||
hashCacheMu sync.Mutex
|
||||
hashCache map[string]string // stamp → 16MB-prefix MD5
|
||||
}
|
||||
|
||||
func danmakuHTTPClient() *http.Client {
|
||||
@@ -89,7 +114,20 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DanmakuService{log: log, repo: repo, client: danmakuHTTPClient()}
|
||||
return &DanmakuService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: danmakuHTTPClient(),
|
||||
hashCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetStrmResolver wires the strm play resolver used to fetch cloud video
|
||||
// bytes for hash computation.
|
||||
func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)) {
|
||||
if s != nil {
|
||||
s.strmResolve = resolve
|
||||
}
|
||||
}
|
||||
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
@@ -121,14 +159,20 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
// media-derived search term (empty = use the video's own name); pass it from
|
||||
// the player when the user searches for a custom title. episodeID forces a
|
||||
// specific danmaku library chosen by the user (from a previous disambiguation
|
||||
// response); empty means auto-resolution. The danmaku library is resolved
|
||||
// through the dandanplay protocol:
|
||||
// response); empty means auto-resolution through the dandanplay protocol:
|
||||
//
|
||||
// 1. search episodes by title (+ season/episode number)
|
||||
// 2a. exactly one hit → fetch that episode's comment library
|
||||
// 2b. several hits → return candidates (Raw empty) so the player asks the user
|
||||
// 2c. explicit episodeID → fetch it directly
|
||||
// 3. fetch the comment library XML
|
||||
// 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,
|
||||
// single hit used, several hits returned as candidates for the player).
|
||||
// 4. 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
|
||||
// always goes to the official endpoint.
|
||||
//
|
||||
// When danmaku is disabled the result carries Enabled=false so the player can
|
||||
// silently skip rendering.
|
||||
@@ -137,33 +181,68 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
if !res.Enabled {
|
||||
return res, nil
|
||||
}
|
||||
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
|
||||
official := danmakuOfficialBase
|
||||
|
||||
base := strings.TrimRight(strings.TrimSpace(res.Source), "/")
|
||||
if base == "" {
|
||||
base = DanmakuDefaultSource
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(episodeID)
|
||||
if target == "" {
|
||||
// 名称与集数优先来自媒体(original_name → title → 文件名),
|
||||
// 手动搜索关键词时仍沿用当前媒体的集数(同一部番剧同名搜索)。
|
||||
term, err := s.searchTerms(ctx, mediaID)
|
||||
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
|
||||
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
|
||||
}
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
return res, nil
|
||||
}
|
||||
res.Raw, res.SourceType = raw, st
|
||||
return res, nil
|
||||
}
|
||||
|
||||
candidates, err := s.searchCandidates(ctx, base, term.name, term.episode)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 多结果歧义:把候选交回播放器让用户选择(disambiguation)。
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
return res, nil
|
||||
@@ -174,13 +253,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
}
|
||||
|
||||
raw, err := s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
|
||||
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 = raw
|
||||
res.SourceType = detectDanmakuSourceType(raw)
|
||||
res.Raw, res.SourceType = raw, st
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -207,19 +285,19 @@ type danmakuSearchTerms struct {
|
||||
}
|
||||
|
||||
// searchTerms resolves the name and episode number used to look up the
|
||||
// dandanplay library. Episode 0 (movies / unknown) is left empty so the
|
||||
// search does not filter by episode.
|
||||
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, error) {
|
||||
// dandanplay library (and the media row for hash identification). Episode 0
|
||||
// (movies / unknown) is left empty so the search does not filter by episode.
|
||||
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
|
||||
var term danmakuSearchTerms
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return term, errors.New("media repository unavailable")
|
||||
return term, nil, errors.New("media repository unavailable")
|
||||
}
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return term, err
|
||||
return term, nil, err
|
||||
}
|
||||
if m == nil {
|
||||
return term, errors.New("media not found")
|
||||
return term, nil, errors.New("media not found")
|
||||
}
|
||||
if name := strings.TrimSpace(m.OriginalName); name != "" {
|
||||
term.name = name
|
||||
@@ -231,7 +309,7 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
}
|
||||
return term, nil
|
||||
return term, m, nil
|
||||
}
|
||||
|
||||
// searchCandidates returns every anime hit for a name via the dandanplay
|
||||
@@ -298,6 +376,17 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
|
||||
}
|
||||
req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
|
||||
req.Header.Set("Accept", "application/json, application/xml, */*")
|
||||
if appID, appKey, ok := s.danmakuCredentials(ctx, sourceURL); ok {
|
||||
// 签名认证:base64(sha256(AppId+Timestamp+Path+Secret)),密钥不出服务器。
|
||||
ts := time.Now().Unix()
|
||||
path := "/"
|
||||
if u, err := url.Parse(sourceURL); err == nil && u.Path != "" {
|
||||
path = u.Path
|
||||
}
|
||||
req.Header.Set("X-AppId", appID)
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
|
||||
req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -312,3 +401,321 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// danmakuCredentials resolves the application credentials for the official
|
||||
// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
|
||||
// win; otherwise the built-in obfuscated fallback pair is used. Returns
|
||||
// ok=false for any other host so credentials — including the built-in pair —
|
||||
// are never sent to third-party dandanplay protocol mirrors. The "official"
|
||||
// host follows danmakuOfficialBase (overridable in tests).
|
||||
func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
official, err := url.Parse(danmakuOfficialBase)
|
||||
if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
|
||||
return "", "", false
|
||||
}
|
||||
var id, key string
|
||||
if s != nil && s.repo != nil && s.repo.Setting != nil {
|
||||
id, _ = s.repo.Setting.Get(ctx, DanmakuAppIDKey)
|
||||
key, _ = s.repo.Setting.Get(ctx, DanmakuAppKeyKey)
|
||||
}
|
||||
id, key = strings.TrimSpace(id), strings.TrimSpace(key)
|
||||
if id != "" && key != "" {
|
||||
return id, key, true
|
||||
}
|
||||
if id != "" || key != "" {
|
||||
s.log.Warn("danmaku credentials incomplete, using built-in fallback",
|
||||
zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
|
||||
}
|
||||
embedID, embedKey := danmakuEmbeddedCredentials()
|
||||
return embedID, embedKey, true
|
||||
}
|
||||
|
||||
// danmakuHashPrefixBytes 是 dandanplay match 规格要求的前 16MB 数据。
|
||||
const danmakuHashPrefixBytes = 16 << 20
|
||||
|
||||
const danmakuHashCacheMax = 256
|
||||
|
||||
func (s *DanmakuService) hashCacheGet(stamp string) (string, bool) {
|
||||
s.hashCacheMu.Lock()
|
||||
defer s.hashCacheMu.Unlock()
|
||||
h, ok := s.hashCache[stamp]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (s *DanmakuService) hashCachePut(stamp, hash string) {
|
||||
s.hashCacheMu.Lock()
|
||||
defer s.hashCacheMu.Unlock()
|
||||
if len(s.hashCache) >= danmakuHashCacheMax {
|
||||
// 简单淘汰:满了整体清空;哈希只用于重复播放时的缓存命中。
|
||||
s.hashCache = make(map[string]string)
|
||||
}
|
||||
s.hashCache[stamp] = hash
|
||||
}
|
||||
|
||||
// danmakuMatchFileName derives the /api/v2/match fileName: base name without
|
||||
// the final extension. .strm items are covered too — MMTL strm files drop the
|
||||
// video extension ("xxx.strm") while pre-existing ones may keep it
|
||||
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
|
||||
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
|
||||
func danmakuMatchFileName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
base = strings.TrimSuffix(base, ext)
|
||||
}
|
||||
if second := strings.ToLower(filepath.Ext(base)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
|
||||
// video). Local videos are hashed straight from disk; .strm indirections are
|
||||
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
|
||||
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
|
||||
if media == nil || media.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
target := media.STRMURL
|
||||
if target == "" {
|
||||
parsed, err := readLocalSTRMTarget(media.Path)
|
||||
if err != nil || parsed == "" {
|
||||
return "", false
|
||||
}
|
||||
target = parsed
|
||||
}
|
||||
return s.hashStrmTarget(ctx, target)
|
||||
}
|
||||
return s.hashLocalFile(media.Path)
|
||||
}
|
||||
|
||||
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
|
||||
// by path+size+mtime so repeated danmaku loads skip the disk read.
|
||||
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return "", false
|
||||
}
|
||||
stamp := fmt.Sprintf("f|%s|%d|%d", path, info.Size(), info.ModTime().UnixNano())
|
||||
if h, ok := s.hashCacheGet(stamp); ok {
|
||||
return h, true
|
||||
}
|
||||
f, err := os.Open(path) // #nosec G304 -- path 来自已入库的媒体行
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(f, danmakuHashPrefixBytes)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
s.hashCachePut(stamp, hash)
|
||||
return hash, true
|
||||
}
|
||||
|
||||
// hashStrmTarget computes the video hash behind a .strm indirection:
|
||||
// MMTL-internal /api/strm/play URLs are resolved through strmResolve (local
|
||||
// path read directly, cloud links range-fetched); plain http(s) links are
|
||||
// fetched directly. Only the 16MB prefix is ever downloaded.
|
||||
func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string, bool) {
|
||||
if h, ok := s.hashCacheGet("s|" + raw); ok {
|
||||
return h, true
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var (
|
||||
src *StrmPlayResult
|
||||
body io.ReadCloser
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(u.Path, "/api/strm/play/"):
|
||||
// /api/strm/play/{provider}/video{ext}?acct=..&pickcode=..
|
||||
segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
|
||||
if len(segs) < 2 || s.strmResolve == nil {
|
||||
return "", false
|
||||
}
|
||||
src, err = s.strmResolve(ctx, segs[0], u.Query())
|
||||
if err != nil || src == nil {
|
||||
return "", false
|
||||
}
|
||||
case u.Scheme == "http" || u.Scheme == "https":
|
||||
src = &StrmPlayResult{RedirectURL: raw}
|
||||
default:
|
||||
// webdav/alist 等协议无法直接用标准 HTTP 拉取,交给搜索层兜底。
|
||||
return "", false
|
||||
}
|
||||
switch {
|
||||
case src.LocalPath != "":
|
||||
return s.hashLocalFile(src.LocalPath)
|
||||
case src.RedirectURL != "":
|
||||
body, err = s.openRangeBody(ctx, src.RedirectURL, nil)
|
||||
case src.Link != nil && src.Link.URL != "":
|
||||
body, err = s.openRangeBody(ctx, src.Link.URL, src.Link.Headers)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if err != nil || body == nil {
|
||||
return "", false
|
||||
}
|
||||
defer body.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
s.hashCachePut("s|"+raw, hash)
|
||||
return hash, true
|
||||
}
|
||||
|
||||
// openRangeBody issues a Range request for the 16MB video prefix. Range is a
|
||||
// suggestion — servers that ignore it are capped by the caller's LimitReader.
|
||||
func (s *DanmakuService) openRangeBody(ctx context.Context, target string, headers map[string]string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", danmakuHashPrefixBytes-1))
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
client := s.client
|
||||
if client == nil {
|
||||
client = danmakuHTTPClient()
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("hash range fetch returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// danmakuMatch mirrors one hit of the /api/v2/match response.
|
||||
type danmakuMatch struct {
|
||||
EpisodeID int64 `json:"episodeId"`
|
||||
AnimeID int64 `json:"animeId"`
|
||||
AnimeTitle string `json:"animeTitle"`
|
||||
EpisodeTitle string `json:"episodeTitle"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
|
||||
// names with errorCode 2 (verified against the live API — QueryEscape's
|
||||
// percent-encoding with "+" for space is accepted).
|
||||
func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash string, fileSize int64, durationSec int) ([]danmakuMatch, error) {
|
||||
appID, appKey, ok := s.danmakuCredentials(ctx, danmakuOfficialBase)
|
||||
if !ok {
|
||||
return nil, errors.New("danmaku credentials unavailable")
|
||||
}
|
||||
payload := struct {
|
||||
FileName string `json:"fileName"`
|
||||
FileHash string `json:"fileHash"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
VideoDuration int `json:"videoDuration"`
|
||||
MatchMode string `json:"matchMode"`
|
||||
}{
|
||||
FileName: url.QueryEscape(fileName),
|
||||
FileHash: fileHash,
|
||||
FileSize: fileSize,
|
||||
VideoDuration: durationSec,
|
||||
MatchMode: "hashAndFileName",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := "/api/v2/match"
|
||||
ts := time.Now().Unix()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, danmakuOfficialBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-AppId", appID)
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
|
||||
req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
return out.Matches, nil
|
||||
}
|
||||
|
||||
// sameDanmakuBase reports whether two source bases point at the same origin
|
||||
// (host, including port) so the fallback does not call the same server twice.
|
||||
func sameDanmakuBase(a, b string) bool {
|
||||
ua, errA := url.Parse(a)
|
||||
ub, errB := url.Parse(b)
|
||||
return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
|
||||
}
|
||||
|
||||
// fetchCommentWithFallback fetches a comment library from the configured
|
||||
// source first (when set and different from official), falling back to the
|
||||
// official endpoint on failure.
|
||||
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
|
||||
var bases []string
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
bases = append(bases, configured)
|
||||
}
|
||||
bases = append(bases, official)
|
||||
var lastErr error
|
||||
for i, base := range bases {
|
||||
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
|
||||
if err == nil {
|
||||
return raw, detectDanmakuSourceType(raw), nil
|
||||
}
|
||||
lastErr = err
|
||||
if i < len(bases)-1 {
|
||||
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return "", "auto", lastErr
|
||||
}
|
||||
|
||||
// searchCandidatesWithFallback searches the configured source first (when
|
||||
// set and different from official), falling back to the official endpoint on
|
||||
// failure.
|
||||
func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
candidates, err := s.searchCandidates(ctx, configured, name, episode)
|
||||
if err == nil {
|
||||
return candidates, nil
|
||||
}
|
||||
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
|
||||
}
|
||||
return s.searchCandidates(ctx, official, name, episode)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user