Files
MeBox/internal/service/danmaku_service.go
T
truewhile 5584862352 处理bug
处理bug
2026-08-25 11:30:51 +08:00

731 lines
26 KiB
Go

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"
)
// Danmaku setting keys, managed through the admin settings UI (PUT
// /admin/settings). They are stored in the Setting table so the playback
// page can pull them without admin privileges.
const (
DanmakuEnabledKey = "danmaku.enabled"
DanmakuSourceKey = "danmaku.source"
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
// admin leaves the source address empty. Any server implementing the
// 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"`
Source string `json:"source,omitempty"`
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
}
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
// comment payload; parsing happens client-side. The dandanplay protocol has
// two payload shapes in the wild — the classic Bilibili-style XML and the
// newer dandanplay JSON ({"count":N,"comments":[{p,m,t,...}]}) — so
// source_type is sniffed from the body rather than fixed.
//
// 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.
type DanmakuFetchResult struct {
DanmakuRenderConfig
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
}
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
// the dandanplay SearchEpisodesResponse shape.
type DanmakuAnime struct {
AnimeID int64 `json:"animeId"`
AnimeTitle string `json:"animeTitle"`
Episodes []DanmakuEpisode `json:"episodes"`
}
// DanmakuEpisode is one selectable danmaku library inside an anime.
type DanmakuEpisode struct {
EpisodeID int64 `json:"episodeId"`
EpisodeTitle string `json:"episodeTitle"`
}
// DanmakuService fetches danmaku for a media item through the dandanplay
// 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 {
return &http.Client{Timeout: 20 * time.Second}
}
func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuService {
if log == nil {
log = zap.NewNop()
}
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.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
Opacity: "1",
FontSize: "24",
Area: "1",
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
}
read := func(key, fallback string) string {
v, err := s.repo.Setting.Get(ctx, key)
if err != nil || v == "" {
return fallback
}
return v
}
cfg.Enabled = ParseBoolSetting(read(DanmakuEnabledKey, "true"), true)
cfg.Source = read(DanmakuSourceKey, "")
cfg.Opacity = read(DanmakuOpacityKey, "1")
cfg.FontSize = read(DanmakuFontSizeKey, "24")
cfg.Area = read(DanmakuAreaKey, "1")
return cfg
}
// Fetch retrieves danmaku for the given media. keyword overrides the
// 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 through the dandanplay protocol:
//
// 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.
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
if !res.Enabled {
return res, nil
}
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 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
}
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)
}
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
}
// detectDanmakuSourceType guesses the comment payload format from its body.
// The dandanplay protocol historically returned Bilibili-style XML, but newer
// and self-hosted implementations return the dandanplay JSON shape
// ({"count":N,"comments":[{p,m,t,...}]}); sniff the leading byte instead of
// trusting the source. Unknown/empty bodies fall back to "auto" so the player
// can try both parsers.
func detectDanmakuSourceType(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "auto"
}
if trimmed[0] == '{' || trimmed[0] == '[' {
return "json"
}
return "xml"
}
type danmakuSearchTerms struct {
name string // 作品标题(original_name → title → 文件名)
episode string // 集数编号(dandanplay episode 参数,正整数)
}
// searchTerms resolves the name and episode number used to look up the
// 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, nil, errors.New("media repository unavailable")
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return term, nil, err
}
if m == nil {
return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
return term, m, nil
}
// searchCandidates returns every anime hit for a name via the dandanplay
// search endpoint, with the episode list per anime. The optional episode
// number filters the result to that specific episode. Responses follow
// SearchEpisodesResponse: { animes: [{ animeId, animeTitle, episodes:
// [{ episodeId, episodeTitle }] }] }.
func (s *DanmakuService) searchCandidates(ctx context.Context, base, name, episode string) ([]DanmakuAnime, error) {
u := fmt.Sprintf("%s/api/v2/search/episodes?anime=%s&v2=true", base, url.QueryEscape(name))
if episode != "" {
u += "&episode=" + url.QueryEscape(episode)
}
body, err := s.fetchBody(ctx, u, false)
if err != nil {
return nil, err
}
var resp struct {
Animes []struct {
AnimeID int64 `json:"animeId"`
AnimeTitle string `json:"animeTitle"`
Episodes []struct {
EpisodeID int64 `json:"episodeId"`
EpisodeTitle string `json:"episodeTitle"`
} `json:"episodes"`
} `json:"animes"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, fmt.Errorf("danmaku search returned invalid JSON: %w", err)
}
var out []DanmakuAnime
for _, anime := range resp.Animes {
item := DanmakuAnime{AnimeID: anime.AnimeID, AnimeTitle: anime.AnimeTitle}
for _, ep := range anime.Episodes {
if ep.EpisodeID <= 0 {
continue
}
item.Episodes = append(item.Episodes, DanmakuEpisode{
EpisodeID: ep.EpisodeID,
EpisodeTitle: ep.EpisodeTitle,
})
}
if len(item.Episodes) == 0 {
continue
}
out = append(out, item)
}
return out, nil
}
func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, followRedirect bool) (string, error) {
client := s.client
if !followRedirect {
// Search returns plain JSON; the comment endpoint 302-redirects to a
// danmaku accelerator CDN which the default client follows.
copied := *s.client
copied.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
client = &copied
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return "", err
}
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
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("danmaku source returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 8 MiB cap
if err != nil {
return "", err
}
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 {
s.log.Warn("danmaku strm resolve failed, hash layer skipped",
zap.String("provider", segs[0]), zap.Error(err))
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 != "":
var headers map[string]string
if src.Link != nil {
headers = src.Link.Headers // 115 直链防盗链要求携带绑定 UA
}
body, err = s.openRangeBody(ctx, src.RedirectURL, headers)
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 {
if err != nil {
s.log.Warn("danmaku hash prefix fetch failed, hash layer skipped", zap.String("target", raw), zap.Error(err))
}
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)
}