diff --git a/go.mod b/go.mod index 73ec6cd..213316e 100644 --- a/go.mod +++ b/go.mod @@ -12,16 +12,19 @@ require ( github.com/redis/go-redis/v9 v9.7.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.21.0 + golang.org/x/sys v0.20.0 gorm.io/driver/postgres v1.5.7 - gorm.io/gorm v1.25.7 + gorm.io/gorm v1.30.0 ) require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect @@ -48,6 +51,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -67,8 +71,7 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.21.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2ca971b..31db162 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -171,15 +175,15 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -187,8 +191,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= -gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= -gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= diff --git a/internal/handler/danmaku.go b/internal/handler/danmaku.go new file mode 100644 index 0000000..696c4dc --- /dev/null +++ b/internal/handler/danmaku.go @@ -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())) + } +} \ No newline at end of file diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index bc712a2..a97abbf 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -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)) diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go new file mode 100644 index 0000000..4765d1b --- /dev/null +++ b/internal/service/danmaku_service.go @@ -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 +} \ No newline at end of file diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go new file mode 100644 index 0000000..fe695a9 --- /dev/null +++ b/internal/service/danmaku_service_test.go @@ -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, `弹幕A弹幕B弹幕C`) + }) + mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `显式指定弹幕`) + }) + 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 + }{ + {`text`, "xml"}, + {`text`, "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") +} diff --git a/internal/service/service.go b/internal/service/service.go index c89cfff..cb21379 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -59,6 +59,7 @@ type Container struct { Cache *RuntimeCacheService Sessions *SessionTrackerService RecognitionWords *RecognitionWordsService + Danmaku *DanmakuService stopCtx context.Context stopCancel context.CancelFunc diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 8abdb30..1280642 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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( diff --git a/web/package-lock.json b/web/package-lock.json index 0d5ed3c..86d123b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,15 +1,16 @@ { - "name": "mediastationgo-web", + "name": "mmtl-web", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mediastationgo-web", + "name": "mmtl-web", "version": "0.1.0", "dependencies": { "axios": "^1.7.2", "clsx": "^2.1.1", + "danmu": "^0.20.0", "framer-motion": "^12.38.0", "hls.js": "^1.5.11", "lucide-react": "^0.379.0", @@ -1763,6 +1764,15 @@ "node": ">= 6.0.0" } }, + "node_modules/aidly": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/aidly/-/aidly-1.37.0.tgz", + "integrity": "sha512-IMbw5e7KKnacRh9PuuHN/65iFibo8NLtqM9NS1QTn7ySqnCVAcc72/Bz0CHnMvwC6FxLtICZWLVIfPddMr9vwA==", + "license": "MIT", + "dependencies": { + "small-queue": "^1.1.2" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2113,6 +2123,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/danmu": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/danmu/-/danmu-0.20.0.tgz", + "integrity": "sha512-J58V1tQZwvEOc8/APOPq9r7Z78+SfGIznPp75zgEsYBVQDQddcOyPuPqMgEzT9XmZk66QKR8ezGGAjzn2gr92w==", + "license": "MIT", + "dependencies": { + "aidly": "^1.36.0", + "hooks-plugin": "^1.3.4" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2852,6 +2872,15 @@ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, + "node_modules/hooks-plugin": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/hooks-plugin/-/hooks-plugin-1.3.4.tgz", + "integrity": "sha512-2cQFChc7gYjM1A2B7N9fmb3iG+0Ns8Fi9FOEg7P3doH9scDSSJf423V7mxPewc/acshGP1StrQynOgSBM8Pkwg==", + "license": "MIT", + "dependencies": { + "aidly": "^1.36.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3855,6 +3884,12 @@ "node": ">=8" } }, + "node_modules/small-queue": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/small-queue/-/small-queue-1.1.2.tgz", + "integrity": "sha512-K6zQPh7cRjkzv+upcwrOTbR4kU3neY3KZR2F1nc8Fuez8uJyh4BguOUs4KGKHcuzU1LJmK8yfCkMUZbconLe8Q==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index 68cae85..f52ce12 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "axios": "^1.7.2", "clsx": "^2.1.1", + "danmu": "^0.20.0", "framer-motion": "^12.38.0", "hls.js": "^1.5.11", "lucide-react": "^0.379.0", diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts new file mode 100644 index 0000000..a022acf --- /dev/null +++ b/web/src/api/danmaku.ts @@ -0,0 +1,65 @@ +import { api } from './client' + +// DanmakuEpisode / DanmakuAnime mirror the dandanplay search response. When +// multiple anime match, the backend returns them as candidates and the player +// asks the user which to load (disambiguation). +export interface DanmakuEpisode { + episodeId: number + episodeTitle: string +} + +export interface DanmakuAnime { + animeId: number + animeTitle: string + episodes: DanmakuEpisode[] +} + +// DanmakuFetchResult mirrors the backend /api/danmaku/:id response: renderer +// knobs (resolved from runtime settings) plus the raw upstream payload. The +// dandanplay protocol delivers Bilibili-format XML or its own JSON shape +// ({comments:[{p,m,t}]}); source_type is sniffed from the body by the backend +// ("auto" when there is nothing to sniff). When several anime matched, +// candidates is non-empty and raw is empty. +export interface DanmakuFetchResult { + enabled: boolean + source_type: 'auto' | 'xml' | 'json' + opacity: string + font_size: string + area: string + raw?: string + candidates?: DanmakuAnime[] +} + +export type DanmakuFetchOptions = { + /** Overrides the media-derived search keyword (manual search). */ + kw?: string + /** Forces a specific danmaku library chosen by the user. */ + episodeId?: number | string +} + +// danmakuAPI fetches danmaku comments for a media item. The backend resolves +// the configured dandanplay source by the video's name (search for episode, +// then fetch its comment library) and returns the raw Bilibili-format XML; +// parsing into comment objects happens client-side in utils/parseDanmaku. +export const danmakuAPI = { + fetch: (mediaId: string, options: DanmakuFetchOptions = {}) => + api + .get(`/danmaku/${encodeURIComponent(mediaId)}`, { + params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined }, + timeout: 20_000, + }) + .then((r) => r.data), + + // config returns the renderer knobs so the player can initialize its + // danmaku control panel without admin privileges. + config: () => + api + .get<{ + enabled: boolean + source?: string + opacity: string + font_size: string + area: string + }>('/danmaku/config') + .then((r) => r.data), +} \ No newline at end of file diff --git a/web/src/components/DanmakuStage.tsx b/web/src/components/DanmakuStage.tsx new file mode 100644 index 0000000..77ba9c2 --- /dev/null +++ b/web/src/components/DanmakuStage.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef } from 'react' +import { create, type Manager, type ManagerPlugin } from 'danmu' + +import { danmakuAPI, type DanmakuAnime } from '../api/danmaku' +import type { Media } from '../types' +import { parseDanmaku, type Comment } from '../utils/parseDanmaku' + +// DanmakuStage overlays danmu (the same engine behind dan-player) onto the +//