mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
添加弹幕功能
添加弹幕功能
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type Container struct {
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user