From 8e15d802865ed57db0018c15ea5559c8bd44c01f Mon Sep 17 00:00:00 2001 From: Keuin Date: Wed, 7 Sep 2022 02:48:46 +0800 Subject: First working version. Just a POC. --- bilibili/client.go | 45 +++++++++++++++++++++ bilibili/danmaku_server_info.go | 28 ++++++++++++++ bilibili/errors.go | 7 ++++ bilibili/logging.go | 13 +++++++ bilibili/model.go | 8 ++++ bilibili/play_url.go | 35 +++++++++++++++++ bilibili/request.go | 68 ++++++++++++++++++++++++++++++++ bilibili/room_profile.go | 86 +++++++++++++++++++++++++++++++++++++++++ bilibili/room_status.go | 58 +++++++++++++++++++++++++++ bilibili/streaming.go | 66 +++++++++++++++++++++++++++++++ bilibili/util.go | 13 +++++++ 11 files changed, 427 insertions(+) create mode 100644 bilibili/client.go create mode 100644 bilibili/danmaku_server_info.go create mode 100644 bilibili/errors.go create mode 100644 bilibili/logging.go create mode 100644 bilibili/model.go create mode 100644 bilibili/play_url.go create mode 100644 bilibili/request.go create mode 100644 bilibili/room_profile.go create mode 100644 bilibili/room_status.go create mode 100644 bilibili/streaming.go create mode 100644 bilibili/util.go (limited to 'bilibili') diff --git a/bilibili/client.go b/bilibili/client.go new file mode 100644 index 0000000..fcea395 --- /dev/null +++ b/bilibili/client.go @@ -0,0 +1,45 @@ +/* +This file defines the Bilibili client struct. +This struct is a facade of all necessary Bilibili HTTP API wrappers. +*/ +package bilibili + +import ( + "context" + "log" + "net/http" + "os" +) + +const ( + // kUserAgent: the default user-agent header to use when communicating with bilibili. + kUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" +) + +type Bilibili struct { + userAgent string + http *http.Client + loggerCommon + ctx context.Context +} + +func NewBilibiliWithContext(ctx context.Context) Bilibili { + logger := loggerCommon{ + debug: log.New(os.Stderr, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile), + info: log.New(os.Stderr, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile), + warn: log.New(os.Stderr, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile), + error: log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile), + } + return Bilibili{ + loggerCommon: logger, + userAgent: kUserAgent, + http: http.DefaultClient, + ctx: ctx, + } +} + +func NewBilibili() Bilibili { + ctx := context.Background() + return NewBilibiliWithContext(ctx) +} diff --git a/bilibili/danmaku_server_info.go b/bilibili/danmaku_server_info.go new file mode 100644 index 0000000..dbdf6bb --- /dev/null +++ b/bilibili/danmaku_server_info.go @@ -0,0 +1,28 @@ +package bilibili + +import ( + "bilibili-livestream-archiver/common" + "fmt" +) + +type DanmakuServerInfoResponse = BaseResponse[danmakuInfo] + +type danmakuInfo struct { + Group string `json:"group"` + BusinessID int `json:"business_id"` + RefreshRowFactor float64 `json:"refresh_row_factor"` + RefreshRate int `json:"refresh_rate"` + MaxDelay int `json:"max_delay"` + Token string `json:"token"` + HostList []struct { + Host string `json:"host"` + Port int `json:"port"` + WssPort int `json:"wss_port"` + WsPort int `json:"ws_port"` + } `json:"host_list"` +} + +func (b Bilibili) GetDanmakuServerInfo(roomId common.RoomId) (resp DanmakuServerInfoResponse, err error) { + url := fmt.Sprintf("https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=%d&type=0", roomId) + return callGet[DanmakuServerInfoResponse](b, url) +} diff --git a/bilibili/errors.go b/bilibili/errors.go new file mode 100644 index 0000000..3f8ac9b --- /dev/null +++ b/bilibili/errors.go @@ -0,0 +1,7 @@ +package bilibili + +import "fmt" + +var ( + ErrRoomIsClosed = fmt.Errorf("living room is closed") +) diff --git a/bilibili/logging.go b/bilibili/logging.go new file mode 100644 index 0000000..a0b9b8e --- /dev/null +++ b/bilibili/logging.go @@ -0,0 +1,13 @@ +/* +This file defines the common struct of logger pointers used in modules of this package. +*/ +package bilibili + +import "log" + +type loggerCommon struct { + debug *log.Logger + info *log.Logger + warn *log.Logger + error *log.Logger +} diff --git a/bilibili/model.go b/bilibili/model.go new file mode 100644 index 0000000..ccffef9 --- /dev/null +++ b/bilibili/model.go @@ -0,0 +1,8 @@ +package bilibili + +type BaseResponse[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + TTL int `json:"ttl"` + Data T `json:"data"` +} diff --git a/bilibili/play_url.go b/bilibili/play_url.go new file mode 100644 index 0000000..282d556 --- /dev/null +++ b/bilibili/play_url.go @@ -0,0 +1,35 @@ +package bilibili + +import ( + "bilibili-livestream-archiver/common" + "fmt" +) + +type RoomUrlInfoResponse = BaseResponse[roomUrlInfo] + +type roomUrlInfo struct { + CurrentQuality int `json:"current_quality"` + AcceptQuality []string `json:"accept_quality"` + CurrentQualityNumber int `json:"current_qn"` + QualityDescription []qualityDescription `json:"quality_description"` + URLs []StreamingUrlInfo `json:"durl"` +} + +type qualityDescription struct { + QualityNumber int `json:"qn"` + Description string `json:"desc"` +} + +type StreamingUrlInfo struct { + URL string `json:"url"` + Length int `json:"length"` + Order int `json:"order"` + StreamType int `json:"stream_type"` + P2pType int `json:"p2p_type"` +} + +func (b Bilibili) GetStreamingInfo(roomId common.RoomId) (resp RoomUrlInfoResponse, err error) { + url := fmt.Sprintf("https://api.live.bilibili.com/room/v1/Room/playUrl?"+ + "cid=%d&otype=json&qn=10000&platform=web", roomId) + return callGet[RoomUrlInfoResponse](b, url) +} diff --git a/bilibili/request.go b/bilibili/request.go new file mode 100644 index 0000000..cb989a7 --- /dev/null +++ b/bilibili/request.go @@ -0,0 +1,68 @@ +package bilibili + +import ( + "encoding/json" + "io" + "log" + "net/http" + "strings" +) + +// newRequest create an HTTP request with per-instance User-Agent set. +func (b Bilibili) newRequest( + method string, + url string, + body io.Reader, +) (req *http.Request, err error) { + req, err = http.NewRequestWithContext(b.ctx, method, url, body) + if err != nil { + b.error.Printf("Cannot create HTTP request instance: %v. Method: %v, URL: %v", err, method, url) + return + } + req.Header.Set("User-Agent", b.userAgent) + return +} + +// newRequest create an HTTP GET request with an empty body and per-instance User-Agent set. +func (b Bilibili) newGet(url string) (req *http.Request, err error) { + return b.newRequest("GET", url, strings.NewReader("")) +} + +// callGet make a GET request and parse response as a JSON document with given model. +func callGet[T BaseResponse[V], V any](b Bilibili, url string) (resp T, err error) { + logger := log.Default() + req, err := b.newGet(url) + if err != nil { + logger.Printf("ERROR: Cannot create HTTP request instance on API %v: %v", url, err) + return + } + + r, err := b.http.Do(req) + defer func() { _ = r.Body.Close() }() + if err != nil { + logger.Printf("ERROR: HTTP Request failed on API %v: %v", url, err) + return + } + + err = validateHttpStatus(r) + if err != nil { + b.error.Printf("%v", err) + return + } + + data, err := io.ReadAll(r.Body) + if err != nil { + b.error.Printf("Error when reading HTTP response on API %v: %v", url, err) + return + } + + err = json.Unmarshal(data, &resp) + if err != nil { + b.error.Printf("Invalid JSON body of HTTP response on API %v: %v. Text: \"%v\"", + url, err, string(data)) + return + } + + b.debug.Printf("HTTP %v, len: %v bytes, url: %v", r.StatusCode, len(data), url) + return +} diff --git a/bilibili/room_profile.go b/bilibili/room_profile.go new file mode 100644 index 0000000..d75d447 --- /dev/null +++ b/bilibili/room_profile.go @@ -0,0 +1,86 @@ +package bilibili + +import ( + "bilibili-livestream-archiver/common" + "fmt" +) + +type roomProfile struct { + UID int `json:"uid"` + RoomID int `json:"room_id"` + ShortID int `json:"short_id"` + Attention int `json:"attention"` + Online int `json:"online"` + IsPortrait bool `json:"is_portrait"` + Description string `json:"description"` + LiveStatus int `json:"live_status"` + AreaID int `json:"area_id"` + ParentAreaID int `json:"parent_area_id"` + ParentAreaName string `json:"parent_area_name"` + OldAreaID int `json:"old_area_id"` + Background string `json:"background"` + Title string `json:"title"` + UserCover string `json:"user_cover"` + Keyframe string `json:"keyframe"` + IsStrictRoom bool `json:"is_strict_room"` + LiveTime string `json:"live_time"` + Tags string `json:"tags"` + IsAnchor int `json:"is_anchor"` + RoomSilentType string `json:"room_silent_type"` + RoomSilentLevel int `json:"room_silent_level"` + RoomSilentSecond int `json:"room_silent_second"` + AreaName string `json:"area_name"` + Pendants string `json:"pendants"` + AreaPendants string `json:"area_pendants"` + HotWords []string `json:"hot_words"` + HotWordsStatus int `json:"hot_words_status"` + Verify string `json:"verify"` + NewPendants struct { + Frame struct { + Name string `json:"name"` + Value string `json:"value"` + Position int `json:"position"` + Desc string `json:"desc"` + Area int `json:"area"` + AreaOld int `json:"area_old"` + BgColor string `json:"bg_color"` + BgPic string `json:"bg_pic"` + UseOldArea bool `json:"use_old_area"` + } `json:"frame"` + Badge struct { + Name string `json:"name"` + Position int `json:"position"` + Value string `json:"value"` + Desc string `json:"desc"` + } `json:"badge"` + MobileFrame struct { + Name string `json:"name"` + Value string `json:"value"` + Position int `json:"position"` + Desc string `json:"desc"` + Area int `json:"area"` + AreaOld int `json:"area_old"` + BgColor string `json:"bg_color"` + BgPic string `json:"bg_pic"` + UseOldArea bool `json:"use_old_area"` + } `json:"mobile_frame"` + MobileBadge interface{} `json:"mobile_badge"` + } `json:"new_pendants"` + UpSession string `json:"up_session"` + PkStatus int `json:"pk_status"` + PkID int `json:"pk_id"` + BattleID int `json:"battle_id"` + AllowChangeAreaTime int `json:"allow_change_area_time"` + AllowUploadCoverTime int `json:"allow_upload_cover_time"` + StudioInfo struct { + Status int `json:"status"` + MasterList []interface{} `json:"master_list"` + } `json:"studio_info"` +} + +type RoomProfileResponse = BaseResponse[roomProfile] + +func (b Bilibili) GetRoomProfile(roomId common.RoomId) (resp RoomProfileResponse, err error) { + url := fmt.Sprintf("https://api.live.bilibili.com/room/v1/Room/get_info?room_id=%d", roomId) + return callGet[RoomProfileResponse](b, url) +} diff --git a/bilibili/room_status.go b/bilibili/room_status.go new file mode 100644 index 0000000..6b6495b --- /dev/null +++ b/bilibili/room_status.go @@ -0,0 +1,58 @@ +/* +Get live room basic status. +This is used to check initially if it is streaming or not. +*/ +package bilibili + +import ( + "bilibili-livestream-archiver/common" + "fmt" +) + +type LiveStatus int + +const ( + Inactive LiveStatus = 0 + Streaming LiveStatus = 1 + Playback LiveStatus = 2 +) + +var liveStatusStringMap = map[LiveStatus]string{ + Inactive: "inactive", + Streaming: "streaming", + Playback: "inactive (playback)", +} + +type roomPlayInfo struct { + RoomID uint64 `json:"room_id"` + ShortID uint `json:"short_id"` + UID uint `json:"uid"` + IsHidden bool `json:"is_hidden"` + IsLocked bool `json:"is_locked"` + IsPortrait bool `json:"is_portrait"` + LiveStatus LiveStatus `json:"live_status"` // 0: inactive 1: streaming 2: playback + HiddenTill int `json:"hidden_till"` + LockTill int `json:"lock_till"` + Encrypted bool `json:"encrypted"` + PwdVerified bool `json:"pwd_verified"` + LiveTime int `json:"live_time"` + RoomShield int `json:"room_shield"` + AllSpecialTypes []interface{} `json:"all_special_types"` + PlayurlInfo interface{} `json:"playurl_info"` +} + +type RoomPlayInfoResponse = BaseResponse[roomPlayInfo] + +func (s LiveStatus) IsStreaming() bool { + return s == Streaming +} + +func (s LiveStatus) String() string { + return liveStatusStringMap[s] +} + +func (b Bilibili) GetRoomPlayInfo(roomId common.RoomId) (resp RoomPlayInfoResponse, err error) { + url := fmt.Sprintf("https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo"+ + "?room_id=%d&protocol=0,1&format=0,1,2&codec=0,1&qn=0&platform=web&ptype=8&dolby=5&panorama=1", roomId) + return callGet[RoomPlayInfoResponse](b, url) +} diff --git a/bilibili/streaming.go b/bilibili/streaming.go new file mode 100644 index 0000000..cc915a3 --- /dev/null +++ b/bilibili/streaming.go @@ -0,0 +1,66 @@ +package bilibili + +import ( + "bilibili-livestream-archiver/common" + "context" + "fmt" + "io" + "net/http" + "strings" +) + +// CopyLiveStream read data from a livestream video stream, copy them to a writer. +func (b Bilibili) CopyLiveStream( + ctx context.Context, + roomId common.RoomId, + stream StreamingUrlInfo, + out io.Writer, +) (err error) { + url := stream.URL + if !strings.HasPrefix(url, "https://") && + !strings.HasPrefix(url, "http://") { + return fmt.Errorf("invalid URL: %v", url) + } + + r, err := b.newGet(url) + if err != nil { + b.error.Printf("Cannot create HTTP GET instance on %v: %v", url, err) + return err + } + + r.Header.Set("Referer", + fmt.Sprintf("https://live.bilibili.com/blanc/%d?liteVersion=true", roomId)) + + resp, err := b.http.Do(r) + if err != nil { + b.error.Printf("Cannot make HTTP GET request on %v: %v\n", url, err) + return + } + + // 404 when not streaming + if resp.StatusCode == http.StatusNotFound { + return ErrRoomIsClosed + } + + err = validateHttpStatus(resp) + if err != nil { + return + } + + defer func() { _ = resp.Body.Close() }() + + // guard the following copy loop + // if the context is cancelled, stop it by closing the reader + guardianCtx, cancelGuardian := context.WithCancel(ctx) + go func() { + <-guardianCtx.Done() + _ = resp.Body.Close() + }() + defer cancelGuardian() + + // blocking copy + n, err := io.Copy(out, resp.Body) + + b.info.Printf("Bytes copied: %v", n) + return +} diff --git a/bilibili/util.go b/bilibili/util.go new file mode 100644 index 0000000..1267a39 --- /dev/null +++ b/bilibili/util.go @@ -0,0 +1,13 @@ +package bilibili + +import ( + "fmt" + "net/http" +) + +func validateHttpStatus(r *http.Response) (err error) { + if code := r.StatusCode; code != http.StatusOK { + err = fmt.Errorf("unsuccessful HTTP status on API %v: %v", r.Request.URL, code) + } + return +} -- cgit v1.2.3