mirror of https://github.com/synctv-org/synctv
feat: bili danmu
parent
7034069983
commit
18c0bcb8aa
@ -0,0 +1,57 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/synctv-org/synctv/internal/op"
|
||||||
|
"github.com/synctv-org/synctv/server/handlers/vendors"
|
||||||
|
"github.com/synctv-org/synctv/server/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StreamDanmu(ctx *gin.Context) {
|
||||||
|
log := ctx.MustGet("log").(*log.Entry)
|
||||||
|
|
||||||
|
room := ctx.MustGet("room").(*op.RoomEntry).Value()
|
||||||
|
// user := ctx.MustGet("user").(*op.UserEntry).Value()
|
||||||
|
|
||||||
|
m, err := room.GetMovieByID(ctx.Param("movieId"))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("get movie by id error: %v", err)
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := vendors.NewVendorService(room, m)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("new vendor service error: %v", err)
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorResp(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
danmu, ok := v.(vendors.VendorDanmuService)
|
||||||
|
if !ok {
|
||||||
|
log.Errorf("vendor %s not support danmu", m.VendorInfo.Vendor)
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewAPIErrorStringResp("vendor not support danmu"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, cancel := context.WithCancel(ctx.Request.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = danmu.StreamDanmu(c, func(danmu string) error {
|
||||||
|
ctx.SSEvent("danmu", danmu)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Writer.Flush()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("stream danmu error: %v", err)
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewAPIErrorResp(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
package vendorbilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
json "github.com/json-iterator/go"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/synctv-org/synctv/internal/vendor"
|
||||||
|
"github.com/synctv-org/synctv/utils"
|
||||||
|
"github.com/synctv-org/vendors/api/bilibili"
|
||||||
|
)
|
||||||
|
|
||||||
|
type command uint32
|
||||||
|
|
||||||
|
const (
|
||||||
|
CMD_HEARTBEAT command = 2
|
||||||
|
CMD_HEARTBEAT_REPLY command = 3
|
||||||
|
CMD_NORMAL command = 5
|
||||||
|
CMD_AUTH command = 7
|
||||||
|
CMD_AUTH_REPLY command = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
type header struct {
|
||||||
|
TotalSize uint32
|
||||||
|
HeaderLen uint16
|
||||||
|
Version uint16
|
||||||
|
Command command
|
||||||
|
Sequence uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerLen = binary.Size(header{})
|
||||||
|
|
||||||
|
func (h *header) Marshal() ([]byte, error) {
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, headerLen))
|
||||||
|
err := binary.Write(buf, binary.BigEndian, h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *header) Unmarshal(data []byte) error {
|
||||||
|
return binary.Read(bytes.NewReader(data), binary.BigEndian, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHeader(size uint32, command command, sequence uint32) header {
|
||||||
|
h := header{
|
||||||
|
TotalSize: uint32(headerLen) + size,
|
||||||
|
HeaderLen: uint16(headerLen),
|
||||||
|
Command: command,
|
||||||
|
Sequence: sequence,
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case CMD_HEARTBEAT, CMD_AUTH:
|
||||||
|
h.Version = 1
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyHello struct {
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
RoomID uint64 `json:"roomid,omitempty"`
|
||||||
|
ProtoVer int `json:"protover,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Type int `json:"type,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVerifyHello(roomID uint64, key string) *verifyHello {
|
||||||
|
return &verifyHello{
|
||||||
|
RoomID: roomID,
|
||||||
|
ProtoVer: 3,
|
||||||
|
Platform: "web",
|
||||||
|
Type: 2,
|
||||||
|
Key: key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVerifyHello(conn *websocket.Conn, hello *verifyHello) error {
|
||||||
|
msg, err := json.Marshal(hello)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header := newHeader(uint32(len(msg)), CMD_AUTH, 1)
|
||||||
|
headerBytes, err := header.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.WriteMessage(websocket.BinaryMessage, append(headerBytes, msg...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeartbeat(conn *websocket.Conn, sequence uint32) error {
|
||||||
|
header := newHeader(0, CMD_HEARTBEAT, sequence)
|
||||||
|
headerBytes, err := header.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.WriteMessage(websocket.BinaryMessage, headerBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
type replyCmd struct {
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliVendorService) StreamDanmu(ctx context.Context, handler func(danmu string) error) error {
|
||||||
|
resp, err := vendor.LoadBilibiliClient("").GetLiveDanmuInfo(ctx, &bilibili.GetLiveDanmuInfoReq{
|
||||||
|
RoomID: v.movie.VendorInfo.Bilibili.Cid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(resp.HostList) == 0 {
|
||||||
|
return errors.New("no host list")
|
||||||
|
}
|
||||||
|
wssHost := resp.HostList[0].Host
|
||||||
|
wssPort := resp.HostList[0].WssPort
|
||||||
|
|
||||||
|
conn, _, err := websocket.
|
||||||
|
DefaultDialer.
|
||||||
|
DialContext(
|
||||||
|
ctx,
|
||||||
|
fmt.Sprintf("wss://%s:%d/sub", wssHost, wssPort),
|
||||||
|
http.Header{
|
||||||
|
"User-Agent": []string{utils.UA},
|
||||||
|
"Origin": []string{"https://live.bilibili.com"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
err = writeVerifyHello(
|
||||||
|
conn,
|
||||||
|
newVerifyHello(
|
||||||
|
v.movie.VendorInfo.Bilibili.Cid,
|
||||||
|
resp.Token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Second * 20)
|
||||||
|
defer ticker.Stop()
|
||||||
|
sequence := uint32(1)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sequence++
|
||||||
|
err = writeHeartbeat(conn, sequence)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("write heartbeat error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header := header{}
|
||||||
|
err = header.Unmarshal(message[:headerLen])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch header.Command {
|
||||||
|
case CMD_HEARTBEAT_REPLY:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := message[headerLen:]
|
||||||
|
switch header.Version {
|
||||||
|
case 2:
|
||||||
|
// zlib
|
||||||
|
zlibReader, err := zlib.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zlibReader.Close()
|
||||||
|
data, err = io.ReadAll(zlibReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
// brotli
|
||||||
|
brotliReader := brotli.NewReader(bytes.NewReader(data))
|
||||||
|
data, err = io.ReadAll(brotliReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = data[headerLen:]
|
||||||
|
}
|
||||||
|
reply := replyCmd{}
|
||||||
|
err = json.Unmarshal(data, &reply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch reply.Cmd {
|
||||||
|
case "DANMU_MSG":
|
||||||
|
danmu := danmuMsg{}
|
||||||
|
err = json.Unmarshal(data, &danmu)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, ok := danmu.Info[1].(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("content is not string")
|
||||||
|
}
|
||||||
|
handler(content)
|
||||||
|
case "DM_INTERACTION":
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type danmuMsg struct {
|
||||||
|
Info []any `json:"info"`
|
||||||
|
}
|
||||||
@ -1 +1 @@
|
|||||||
Subproject commit f737630257f56b6c703853a36d626786fe2a6bd9
|
Subproject commit 06afd9cf4a261f8395d09f7859db9ec40abc67c7
|
||||||
@ -1 +1 @@
|
|||||||
Subproject commit e31a7149e750fc145c2559b7023082dd16718726
|
Subproject commit 54947d824dd71a2bc3aff1ba572470cd31261dec
|
||||||
Loading…
Reference in New Issue