添加弹幕功能

添加弹幕功能
This commit is contained in:
truewhile
2026-08-24 00:37:25 +08:00
parent 71bf60c69c
commit cd1297a30f
20 changed files with 2038 additions and 59 deletions
+33
View File
@@ -0,0 +1,33 @@
// Package handler — danmaku endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
// search keyword (used by the player's manual search box); ?episodeId= forces a
// specific danmaku library chosen by the user after a disambiguation.
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
// size, area, enabled) so the player can initialize its control panel without
// admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
}
}
@@ -10,6 +10,9 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
authed.GET("/media/recent", recentMediaHandler(svc))
authed.GET("/media/stats", mediaStatsHandler(svc))
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
authed.GET("/watch-history/continue", historyContinueHandler(svc))
+314
View File
@@ -0,0 +1,314 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/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"
)
// 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"
// 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: 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
}
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()}
}
// 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. The danmaku library is resolved
// 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
//
// 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
}
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 err != nil {
return res, err
}
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
candidates, err := s.searchCandidates(ctx, base, 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
}
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, err := s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
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)
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. 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) {
var term danmakuSearchTerms
if s == nil || s.repo == nil || s.repo.Media == nil {
return term, errors.New("media repository unavailable")
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return term, err
}
if m == nil {
return term, 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, 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", "MediaStationGo/danmaku (+https://github.com/ShukeBta/MediaStationGo)")
req.Header.Set("Accept", "application/json, application/xml, */*")
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
}
+314
View File
@@ -0,0 +1,314 @@
package service
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func newDanmakuTestService(t *testing.T) *DanmakuService {
t.Helper()
// 独立临时文件库,避免测试间通过共享内存库串数据。
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
repos := repository.New(db)
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return NewDanmakuService(zap.NewNop(), repos)
}
// seedDanmakuMedia inserts a media row so the service can resolve a name and
// episode number.
func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName string, episodeNum int) {
t.Helper()
m := model.Media{Title: title}
if id != "" {
m.ID = id
}
if originalName != "" {
m.OriginalName = originalName
}
if episodeNum > 0 {
m.EpisodeNum = episodeNum
}
require.NoError(t, svc.repo.DB.Create(&m).Error)
}
// danmakuSourceServer records the search request (name + episode params) and
// serves episodes + Bilibili-format XML comments, following the dandanplay
// protocol. The search payload can be customized per test (default: one anime,
// one episode).
type danmakuSourceServer struct {
server *httptest.Server
lastSearch string // full query (anime=...&episode=...)
searchResponse string // JSON body served for /api/v2/search/episodes
}
func newDanmakuSourceServer(t *testing.T) *danmakuSourceServer {
return newDanmakuSourceServerWithSearch(
t,
`{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`,
)
}
func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danmakuSourceServer {
t.Helper()
ds := &danmakuSourceServer{searchResponse: searchResponse}
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
ds.lastSearch = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, ds.searchResponse)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><i><d p="0.5,1,16777215,user1">弹幕A</d><d p="1.0,5,255,user2">弹幕B</d><d p="1.5,4,65280,user3">弹幕C</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><i><d p="2.0,1,16777215,userX">显式指定弹幕</d></i>`)
})
ds.server = httptest.NewServer(mux)
t.Cleanup(ds.server.Close)
return ds
}
func (ds *danmakuSourceServer) URL() string { return ds.server.URL }
func TestDanmakuConfigDefaults(t *testing.T) {
svc := newDanmakuTestService(t)
cfg := svc.Config(context.Background())
require.True(t, cfg.Enabled)
require.Equal(t, "1", cfg.Opacity)
require.Equal(t, "24", cfg.FontSize)
require.Equal(t, "1", cfg.Area)
require.Empty(t, cfg.Source)
}
func TestDanmakuConfigReadsPersistedValues(t *testing.T) {
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false"))
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, "https://dm.example.com"))
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuOpacityKey, "0.7"))
cfg := svc.Config(ctx)
require.False(t, cfg.Enabled)
require.Equal(t, "https://dm.example.com", cfg.Source)
require.Equal(t, "0.7", cfg.Opacity)
}
func TestDanmakuFetchDisablesWhenToggleOff(t *testing.T) {
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false"))
seedDanmakuMedia(t, svc, "m1", "测试动画", "", 0)
res, err := svc.Fetch(ctx, "m1", "", "")
require.NoError(t, err)
require.False(t, res.Enabled)
require.Empty(t, res.Raw)
// 禁用时无弹幕数据可探测,source_type 保持未知(交给播放器兜底)。
require.Equal(t, "auto", res.SourceType)
}
func TestDanmakuFetchWithDandanplaySource(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
seedDanmakuMedia(t, svc, "m2", "测试动画", "", 0)
res, err := svc.Fetch(ctx, "m2", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "xml", res.SourceType)
require.Contains(t, res.Raw, "弹幕A")
require.Contains(t, res.Raw, `p="0.5,1,16777215,user1"`)
}
func TestDanmakuFetchUsesOriginalNameForSearch(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
seedDanmakuMedia(t, svc, "m3", "刮削标题A", "日文原名B", 0)
res, err := svc.Fetch(ctx, "m3", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕A")
// 集数为 0 时不附加 episode 过滤参数。
require.NotContains(t, srv.lastSearch, "episode=")
}
func TestDanmakuFetchSearchesByEpisodeNumber(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
// 第 3 话:搜索时应带 episode=3,命中该集弹幕库。
seedDanmakuMedia(t, svc, "m2", "测试动画", "", 3)
res, err := svc.Fetch(ctx, "m2", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, srv.lastSearch, "anime=")
require.Contains(t, srv.lastSearch, "episode=3")
require.Contains(t, res.Raw, "弹幕A")
}
func TestDanmakuFetchKeywordKeepsEpisode(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
seedDanmakuMedia(t, svc, "m5", "测试动画", "", 5)
// 手动搜索关键词时保留媒体集数,仍只命中该集。
res, err := svc.Fetch(ctx, "m5", "另一个名字", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, srv.lastSearch, "anime=")
require.Contains(t, srv.lastSearch, "episode=5")
}
func TestDanmakuFetchUsesDefaultSourceWhenEmpty(t *testing.T) {
require.Equal(t, DanmakuDefaultSource, "https://api.dandanplay.net")
}
func TestDanmakuFetchHandlesSearch404(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "m4", "测试动画", "", 0)
res, err := svc.Fetch(ctx, "m4", "", "")
require.Error(t, err)
require.True(t, res.Enabled)
}
// 多部番剧命中时返回候选列表(disambiguation),不擅自选第一个。
func TestDanmakuFetchReturnsCandidatesOnAmbiguity(t *testing.T) {
srv := newDanmakuSourceServerWithSearch(
t,
`{"hasMore":false,"animes":[`+
`{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]},`+
`{"animeId":2002,"animeTitle":"测试动画 剧场版","episodes":[{"episodeId":30001,"episodeTitle":"正片"}]}`+
`]}`,
)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
seedDanmakuMedia(t, svc, "m6", "测试动画", "", 0)
res, err := svc.Fetch(ctx, "m6", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
// 歧义时不取弹幕,返回候选交由播放器选择。
require.Empty(t, res.Raw)
require.Len(t, res.Candidates, 2)
require.Equal(t, int64(1001), res.Candidates[0].AnimeID)
require.Equal(t, "测试动画", res.Candidates[0].AnimeTitle)
require.Len(t, res.Candidates[0].Episodes, 1)
require.Equal(t, int64(25484), res.Candidates[0].Episodes[0].EpisodeID)
}
// 显式指定 episodeId 时跳过搜索,直接拉取该弹幕库。
func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
// 媒体名与搜索无关也能用显式 episodeId 命中。
res, err := svc.Fetch(ctx, "m2", "", "99999")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "显式指定弹幕")
require.Empty(t, res.Candidates)
}
func TestDetectDanmakuSourceType(t *testing.T) {
cases := []struct {
raw string
want string
}{
{`<?xml version="1.0"?><i><d p="1,1,25,16777215,x,y,z,w">text</d></i>`, "xml"},
{`<d p="1,1,16777215,u">text</d>`, "xml"},
{`{"count":2,"comments":[{"p":"0.00,1,16777215,[u]","m":"hi","t":0}]}`, "json"},
{`[{"time":1,"text":"x"}]`, "json"},
{"", "auto"},
{" \n ", "auto"},
}
for _, c := range cases {
if got := detectDanmakuSourceType(c.raw); got != c.want {
t.Errorf("detectDanmakuSourceType(%q) = %q, want %q", c.raw, got, c.want)
}
}
}
// 弹弹play JSON 源:comment 接口返回 {"count":N,"comments":[{p,m,t}]},
// Fetch 应自动探测 source_type=json 并原样透传。
func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/search/episodes":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
case "/api/v2/comment/25484":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"count":2,"comments":[{"cid":1,"p":"0.00,1,16777215,[u]","m":"第一弹","t":0},{"cid":2,"p":"3.50,5,16711680,[u]","m":"顶部弹幕","t":3.5}]}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "mjson", "测试动画", "", 0)
res, err := svc.Fetch(ctx, "mjson", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "json", res.SourceType)
require.Contains(t, res.Raw, `"m":"第一弹"`)
require.Empty(t, res.Candidates)
// XML 源仍按 XML 探测。
srvXML := newDanmakuSourceServer(t)
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srvXML.URL()))
res2, err := svc.Fetch(ctx, "mjson", "", "")
require.NoError(t, err)
require.Equal(t, "xml", res2.SourceType)
require.Contains(t, res2.Raw, "弹幕A")
}
+1
View File
@@ -59,6 +59,7 @@ type Container struct {
Cache *RuntimeCacheService
Sessions *SessionTrackerService
RecognitionWords *RecognitionWordsService
Danmaku *DanmakuService
stopCtx context.Context
stopCancel context.CancelFunc
+1
View File
@@ -65,6 +65,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
b.c.Douban = NewDoubanProvider(b.cfg, b.log)
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
b.c.Danmaku = NewDanmakuService(b.log, b.repos)
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
b.c.Scraper = NewScraperService(