summaryrefslogtreecommitdiff
path: root/bilibili
diff options
context:
space:
mode:
Diffstat (limited to 'bilibili')
-rw-r--r--bilibili/client.go45
-rw-r--r--bilibili/danmaku_server_info.go28
-rw-r--r--bilibili/errors.go7
-rw-r--r--bilibili/logging.go13
-rw-r--r--bilibili/model.go8
-rw-r--r--bilibili/play_url.go35
-rw-r--r--bilibili/request.go68
-rw-r--r--bilibili/room_profile.go86
-rw-r--r--bilibili/room_status.go58
-rw-r--r--bilibili/streaming.go66
-rw-r--r--bilibili/util.go13
11 files changed, 427 insertions, 0 deletions
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
+}