├── internal ├── tui │ ├── pk_battle.go │ ├── common.go │ ├── process.go │ ├── widget.go │ ├── login_model.go │ └── model.go └── live_room │ ├── message_stream_test.go │ ├── live_info.go │ ├── common.go │ ├── wbi.go │ ├── user_info.go │ └── message_stream.go ├── img └── bililive1.png ├── start.bat ├── config.toml ├── api ├── config.go └── live_room.go ├── cmd └── tui │ └── main.go ├── README.MD ├── LICENSE ├── .github └── workflows │ └── release.yaml ├── go.mod ├── pkg └── logging │ └── logger.go └── go.sum /internal/tui/pk_battle.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // 这里是pk连线面板 4 | -------------------------------------------------------------------------------- /img/bililive1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shr-go/bili_live_tui/HEAD/img/bililive1.png -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | PowerShell -Command "& {[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8};.\bililive.exe" -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | room_id = 7777 2 | chat_buffer = 200 3 | show_room_title = true 4 | show_room_number = true 5 | color_mode = true 6 | show_ship_level = true 7 | show_medal_name = true 8 | show_medal_level = true 9 | # user_agent = "" -------------------------------------------------------------------------------- /api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type BiliLiveConfig struct { 4 | RoomID uint64 `toml:"room_id"` 5 | ChatBuffer int `toml:"chat_buffer"` 6 | ShowShipLevel bool `toml:"show_ship_level"` 7 | ShowMedalName bool `toml:"show_medal_name"` 8 | ShowMedalLevel bool `toml:"show_medal_level"` 9 | ColorMode bool `toml:"color_mode"` 10 | ShowRoomTitle bool `toml:"show_room_title"` 11 | ShowRoomNumber bool `toml:"show_room_number"` 12 | UserAgent string `toml:"user_agent"` 13 | } 14 | -------------------------------------------------------------------------------- /cmd/tui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/shr-go/bili_live_tui/internal/tui" 6 | "github.com/shr-go/bili_live_tui/pkg/logging" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | logging.Infof("tui start") 12 | client := tui.GetCustomHttpClient() 13 | room, err := tui.PrepareEnterRoom(client) 14 | if err != nil || room == nil { 15 | logging.Fatalf("Connect server error, err=%v", err) 16 | } 17 | p := tea.NewProgram(tui.InitialModel(room), tea.WithAltScreen(), tea.WithMouseCellMotion()) 18 | go tui.ReceiveMsg(p, room) 19 | go tui.PoolWindowSize(p) 20 | if err := p.Start(); err != nil { 21 | logging.Fatalf("Alas, there's been an error: %v", err) 22 | os.Exit(1) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # BILILIVE_TUI 2 | 一个用于黑听B站直播的tui客户端 3 | ![示例图](./img/bililive1.png) 4 | 5 | # 功能 6 | - 查看弹幕、发送弹幕 7 | - 友好的登录方式 8 | - 彩色弹幕显示 9 | - 自适应终端大小 10 | 11 | # 如何使用 12 | ## 配置文件 13 | 目前配置文件比较简单,只有两个设置选项 14 | ```toml 15 | room_id = 7777 # 想登录的直播间ID 16 | chat_buffer = 200 # 可以回滚多少条弹幕 17 | ``` 18 | 19 | ## 启动软件 20 | ### `linux`或`macos` 21 | 直接打开二进制文件即可 22 | ### `windows` 23 | 由于终端编码的支持,需要用以下参数启动 24 | ```shell 25 | PowerShell -Command "& {[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8};.\bililive.exe" 26 | ``` 27 | 或者直接运行release内的start.bat 28 | 29 | ## 登录 30 | 直接扫描二维码即可。 31 | 由于在部分终端下,二维码无法正常显示,所以同时将二维码保存为文件`login.png`,扫描该文件也可完成登录 32 | 33 | ## 操作方式 34 | 按`tab`切换区域,在上方弹幕区域可以用上下左右或者类vim的方式或这直接鼠标滚轮移动 35 | 在下方区域则可以输入弹幕按回车发送 36 | 37 | # 计划实现的功能 38 | - 显示高能榜 39 | - 显示礼物、进场、SC等 40 | - 赠送礼物 41 | - 自动守塔 42 | - 支持充值(真DD) 43 | - 统计直播的数据 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 shr-go 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yaml 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | releases-matrix: 9 | name: Release Go Binary 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | goos: [ linux, darwin ] 14 | goarch: [ "386", amd64, arm64 ] 15 | exclude: 16 | - goarch: "386" 17 | goos: darwin 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: wangyoucao577/go-release-action@v1 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | goos: ${{ matrix.goos }} 24 | goarch: ${{ matrix.goarch }} 25 | project_path: "./cmd/tui" 26 | binary_name: "bililive" 27 | extra_files: LICENSE README.MD config.toml 28 | 29 | releases-win-matrix: 30 | name: Release Go Binary 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | goos: [ windows ] 35 | goarch: [ "386", amd64, arm64 ] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: wangyoucao577/go-release-action@v1 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | goos: ${{ matrix.goos }} 42 | goarch: ${{ matrix.goarch }} 43 | project_path: "./cmd/tui" 44 | binary_name: "bililive" 45 | extra_files: LICENSE README.MD config.toml start.bat 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shr-go/bili_live_tui 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.2.0 7 | github.com/andybalholm/brotli v1.0.4 8 | github.com/charmbracelet/bubbles v0.14.0 9 | github.com/charmbracelet/bubbletea v0.22.1 10 | github.com/charmbracelet/lipgloss v0.6.0 11 | github.com/google/go-querystring v1.1.0 12 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 13 | go.uber.org/zap v1.23.0 14 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 15 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 16 | ) 17 | 18 | require ( 19 | github.com/atotto/clipboard v0.1.4 // indirect 20 | github.com/containerd/console v1.0.3 // indirect 21 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 22 | github.com/mattn/go-isatty v0.0.16 // indirect 23 | github.com/mattn/go-localereader v0.0.1 // indirect 24 | github.com/mattn/go-runewidth v0.0.13 // indirect 25 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 26 | github.com/muesli/cancelreader v0.2.2 // indirect 27 | github.com/muesli/reflow v0.3.0 // indirect 28 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect 29 | github.com/rivo/uniseg v0.2.0 // indirect 30 | go.uber.org/atomic v1.7.0 // indirect 31 | go.uber.org/multierr v1.6.0 // indirect 32 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 33 | golang.org/x/text v0.3.8 // indirect 34 | gopkg.in/yaml.v2 v2.4.0 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /internal/live_room/message_stream_test.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func AssertEqual(t *testing.T, a interface{}, b interface{}) { 12 | if a == b { 13 | return 14 | } 15 | t.Errorf("Received %v (type %v), expected %v (type %v)", a, reflect.TypeOf(a), b, reflect.TypeOf(b)) 16 | } 17 | 18 | func TestDanmuInfo(t *testing.T) { 19 | client := &http.Client{} 20 | info, err := GetDanmuInfo(client, 3) 21 | if err != nil { 22 | t.Logf("GetDanmuInfo Error, %v\n", err) 23 | return 24 | } 25 | t.Logf("%+v", info) 26 | } 27 | 28 | func TestConnect(t *testing.T) { 29 | client := &http.Client{} 30 | info, err := GetDanmuInfo(client, 3) 31 | if err != nil { 32 | t.Logf("GetDanmuInfo Error, %v\n", err) 33 | return 34 | } 35 | _, err = ConnectDanmuServer(0, 3, info) 36 | if err != nil { 37 | t.Logf("Connect Error, %v\n", err) 38 | return 39 | } 40 | } 41 | 42 | func TestConnectDanmuServer(t *testing.T) { 43 | uid := uint64(0) 44 | roomID := uint64(545068) 45 | 46 | client := &http.Client{} 47 | info, err := GetDanmuInfo(client, roomID) 48 | if err != nil { 49 | t.Logf("GetDanmuInfo Error, %v\n", err) 50 | return 51 | } 52 | 53 | server, err := ConnectDanmuServer(uid, roomID, info) 54 | if err != nil { 55 | t.Logf("ConnectDanmuServer Error, %v\n", err) 56 | return 57 | } 58 | time.Sleep(600 * time.Second) 59 | close(server.DoneChan) 60 | fmt.Println("Close DoneChan") 61 | time.Sleep(10 * time.Second) 62 | } 63 | -------------------------------------------------------------------------------- /internal/live_room/live_info.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/google/go-querystring/query" 8 | "github.com/shr-go/bili_live_tui/api" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | func GetRoomInfo(client *http.Client, roomID uint64) (info *api.RoomInfoResp, err error) { 14 | roomInfoReq := api.RoomInfoReq{RoomID: roomID} 15 | v, err := query.Values(roomInfoReq) 16 | if err != nil { 17 | return 18 | } 19 | baseURL := "https://api.live.bilibili.com/room/v1/Room/get_info" 20 | realUrl := fmt.Sprintf("%s?%s", baseURL, v.Encode()) 21 | resp, err := client.Get(realUrl) 22 | if err != nil { 23 | return 24 | } 25 | defer resp.Body.Close() 26 | body, err := io.ReadAll(resp.Body) 27 | if err != nil { 28 | return 29 | } 30 | roomInfoResp := new(api.RoomInfoResp) 31 | err = json.Unmarshal(body, roomInfoResp) 32 | if err != nil { 33 | return 34 | } 35 | if roomInfoResp.Code != 0 { 36 | err = errors.New(roomInfoResp.Message) 37 | } 38 | info = roomInfoResp 39 | return 40 | } 41 | 42 | //GetUserRoomInfo this function trigger user enter room event 43 | func GetUserRoomInfo(client *http.Client, roomID uint64) (info *api.UserRoomInfo, err error) { 44 | roomInfoReq := api.RoomInfoReq{RoomID: roomID} 45 | v, err := query.Values(roomInfoReq) 46 | if err != nil { 47 | return 48 | } 49 | baseURL := "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByUser" 50 | realUrl := fmt.Sprintf("%s?%s", baseURL, v.Encode()) 51 | resp, err := client.Get(realUrl) 52 | if err != nil { 53 | return 54 | } 55 | defer resp.Body.Close() 56 | body, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | return 59 | } 60 | userRoomInfo := new(api.UserRoomInfo) 61 | err = json.Unmarshal(body, userRoomInfo) 62 | if err != nil { 63 | return 64 | } 65 | if userRoomInfo.Code != 0 { 66 | err = errors.New(userRoomInfo.Message) 67 | } 68 | info = userRoomInfo 69 | return 70 | } 71 | -------------------------------------------------------------------------------- /internal/tui/common.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/shr-go/bili_live_tui/api" 7 | "github.com/shr-go/bili_live_tui/internal/live_room" 8 | "github.com/shr-go/bili_live_tui/pkg/logging" 9 | "golang.org/x/term" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | var ( 15 | windowWidth int 16 | windowHeight int 17 | LiveConfig api.BiliLiveConfig 18 | ) 19 | 20 | func init() { 21 | logging.InitLogConfig() 22 | windowWidth, windowHeight, _ = term.GetSize(int(os.Stdout.Fd())) 23 | f := "config.toml" 24 | _, err := toml.DecodeFile(f, &LiveConfig) 25 | if err != nil { 26 | logging.Fatalf("load config error, err=%v", err) 27 | } 28 | } 29 | 30 | type userAgentTransport struct { 31 | ua string 32 | rt http.RoundTripper 33 | } 34 | 35 | func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { 36 | req.Header.Set("User-Agent", t.ua) 37 | return t.rt.RoundTrip(req) 38 | } 39 | 40 | func GetCustomHttpClient() (client *http.Client) { 41 | ua := LiveConfig.UserAgent 42 | if ua == "" { 43 | ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" 44 | } 45 | transport := &userAgentTransport{ 46 | ua: ua, 47 | rt: http.DefaultTransport, 48 | } 49 | return &http.Client{ 50 | Transport: transport, 51 | } 52 | } 53 | 54 | func PrepareEnterRoom(client *http.Client) (room *api.LiveRoom, err error) { 55 | loginModel := newLoginModel(client) 56 | if cookieBytes, err := os.ReadFile("COOKIE.DAT"); err == nil { 57 | cookies := string(cookieBytes) 58 | if live_room.CheckCookieValid(client, cookies) { 59 | loginModel.step = loginStepLoginSuccess 60 | loginModel.localCookie = true 61 | } 62 | } 63 | p := tea.NewProgram(&loginModel, tea.WithAltScreen(), tea.WithMouseCellMotion()) 64 | if err := p.Start(); err != nil { 65 | logging.Fatalf("PrepareEnterRoom ui error: %v", err) 66 | os.Exit(1) 67 | } 68 | if loginModel.quit { 69 | os.Exit(0) 70 | } 71 | return loginModel.room, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/tui/process.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/shr-go/bili_live_tui/api" 7 | "mime/multipart" 8 | "reflect" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | func processDanmuMsg(msg *api.DanmuMessage) (danmu *danmuMsg) { 14 | defer func() { 15 | if r := recover(); r != nil { 16 | danmu = nil 17 | } 18 | }() 19 | rawBasicInfo := msg.Info[0].([]interface{}) 20 | content := msg.Info[1].(string) 21 | rawUserInfo := msg.Info[2].([]interface{}) 22 | rawMedalInfo := msg.Info[3].([]interface{}) 23 | 24 | var medal *medalInfo 25 | if len(rawMedalInfo) > 10 { 26 | medal = new(medalInfo) 27 | medal.level = uint8(rawMedalInfo[0].(float64)) 28 | medal.shipLevel = uint8(rawMedalInfo[10].(float64)) 29 | medal.name = rawMedalInfo[1].(string) 30 | medal.medalColor = fmt.Sprintf("#%06X", int64(rawMedalInfo[4].(float64))) 31 | } 32 | danmu = &danmuMsg{ 33 | uid: uint64(rawUserInfo[0].(float64)), 34 | uName: rawUserInfo[1].(string), 35 | chatTime: time.UnixMilli(int64(rawBasicInfo[4].(float64))), 36 | content: content, 37 | medal: medal, 38 | nameColor: rawUserInfo[7].(string), 39 | contentColor: fmt.Sprintf("#%06X", int64(rawBasicInfo[3].(float64))), 40 | } 41 | return 42 | } 43 | 44 | func generateFakeDanmuMsg(content string) (danmu *danmuMsg) { 45 | danmu = &danmuMsg{ 46 | uid: 10000, 47 | uName: "【未登录 这是一条假弹幕】", 48 | chatTime: time.Now(), 49 | content: content, 50 | medal: nil, 51 | nameColor: "#DC143C", 52 | contentColor: "#DC143C", 53 | } 54 | return danmu 55 | } 56 | 57 | func generateDanmuMsg(content string, room *api.LiveRoom) (danmu *api.SendMsgReq) { 58 | property := room.RoomUserInfo 59 | return &api.SendMsgReq{ 60 | Bubble: property.Bubble, 61 | Msg: content, 62 | Color: property.Danmu.Color, 63 | Mode: property.Danmu.Mode, 64 | Fontsize: 25, 65 | Rnd: time.Now().Unix(), 66 | RoomID: room.RoomID, 67 | CSRF: room.CSRF, 68 | CSRFToken: room.CSRF, 69 | } 70 | } 71 | 72 | func packDanmuMsgForm(danmu *api.SendMsgReq) (contentType string, form *bytes.Buffer) { 73 | bodyBuf := &bytes.Buffer{} 74 | bodyWriter := multipart.NewWriter(bodyBuf) 75 | v := reflect.ValueOf(danmu).Elem() 76 | t := reflect.TypeOf(danmu).Elem() 77 | for i := 0; i < v.NumField(); i++ { 78 | key := t.Field(i).Tag.Get("url") 79 | vi := v.Field(i).Interface() 80 | switch value := vi.(type) { 81 | case int: 82 | bodyWriter.WriteField(key, strconv.Itoa(value)) 83 | case int64: 84 | bodyWriter.WriteField(key, strconv.FormatInt(value, 10)) 85 | case uint64: 86 | bodyWriter.WriteField(key, strconv.FormatUint(value, 10)) 87 | case string: 88 | bodyWriter.WriteField(key, value) 89 | } 90 | } 91 | bodyWriter.Close() 92 | contentType = bodyWriter.FormDataContentType() 93 | form = bodyBuf 94 | return 95 | } 96 | -------------------------------------------------------------------------------- /internal/live_room/common.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/google/go-querystring/query" 8 | "github.com/shr-go/bili_live_tui/api" 9 | "github.com/shr-go/bili_live_tui/pkg/logging" 10 | "io" 11 | "net/http" 12 | "time" 13 | ) 14 | 15 | func AuthAndConnect(client *http.Client, roomID uint64) (room *api.LiveRoom, err error) { 16 | uid := uint64(0) 17 | if userInfo := GetUserInfo(client); userInfo != nil { 18 | uid = userInfo.Data.Mid 19 | } 20 | roomInfo, err := GetRoomInfo(client, roomID) 21 | if err != nil { 22 | return 23 | } 24 | realRoomID := uint64(roomInfo.Data.RoomId) 25 | info, err := GetDanmuInfo(client, realRoomID) 26 | if err != nil { 27 | return 28 | } 29 | room, err = ConnectDanmuServer(uid, realRoomID, info) 30 | if err != nil { 31 | return 32 | } 33 | room.Title = roomInfo.Data.Title 34 | room.ShortID = uint64(roomInfo.Data.ShortId) 35 | room.OwnerId = uint64(roomInfo.Data.Uid) 36 | room.Client = client 37 | 38 | if CheckAuth(client) { 39 | userRoomInfo, err := GetUserRoomInfo(client, realRoomID) 40 | if err != nil { 41 | return nil, err 42 | } 43 | roomUserInfo := userRoomInfo.Data.Property 44 | room.RoomUserInfo = &roomUserInfo 45 | room.CSRF = getCSRF(client) 46 | // 处理心跳 47 | go processHeartBeat(room) 48 | } else { 49 | room.RoomUserInfo = nil 50 | } 51 | return 52 | } 53 | 54 | func processHeartBeat(room *api.LiveRoom) { 55 | nextInterval := 20 56 | heartBeatTicker := time.NewTicker(time.Duration(nextInterval) * time.Second) 57 | defer heartBeatTicker.Stop() 58 | Loop: 59 | for { 60 | select { 61 | case <-room.DoneChan: 62 | break Loop 63 | case <-heartBeatTicker.C: 64 | newNextInterval := roomHeartBeatReq(room.Client, nextInterval, room.RoomID) 65 | if newNextInterval != nextInterval { 66 | nextInterval = newNextInterval 67 | heartBeatTicker.Reset(time.Duration(nextInterval) * time.Second) 68 | } 69 | } 70 | } 71 | } 72 | 73 | func roomHeartBeatReq(client *http.Client, nextInterval int, realRoomID uint64) int { 74 | logging.Debugf("roomHeartBeatReq, nextInterval=%d, realRoomID=%d", nextInterval, realRoomID) 75 | hb := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%d|1|0", nextInterval, realRoomID))) 76 | params := struct { 77 | HB string `url:"hb"` 78 | PF string `url:"pf"` 79 | }{ 80 | HB: hb, 81 | PF: "web", 82 | } 83 | v, err := query.Values(params) 84 | if err != nil { 85 | logging.Errorf("heart beat error, err=%v", err) 86 | } 87 | baseURL := "https://live-trace.bilibili.com/xlive/rdata-interface/v1/heartbeat/webHeartBeat" 88 | realUrl := fmt.Sprintf("%s?%s", baseURL, v.Encode()) 89 | resp, err := client.Get(realUrl) 90 | defer resp.Body.Close() 91 | body, err := io.ReadAll(resp.Body) 92 | if err != nil { 93 | logging.Errorf("heart beat error, err=%v", err) 94 | } 95 | var data struct { 96 | Code int `json:"code"` 97 | Message string `json:"message"` 98 | Ttl int `json:"ttl"` 99 | Data struct { 100 | NextInterval int `json:"next_interval"` 101 | } `json:"data"` 102 | } 103 | 104 | if err = json.Unmarshal(body, &data); err != nil || data.Code != 0 { 105 | logging.Errorf("heart beat error, err=%v, data=%v", err, data) 106 | } 107 | return data.Data.NextInterval 108 | } 109 | -------------------------------------------------------------------------------- /internal/live_room/wbi.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | func signAndGenerateURL(urlStr string) (string, error) { 18 | u, err := url.Parse(urlStr) 19 | if err != nil { 20 | return "", err 21 | } 22 | err = Sign(u) 23 | if err != nil { 24 | return "", fmt.Errorf("sign error: %w", err) 25 | } 26 | return u.String(), nil 27 | } 28 | 29 | // Sign 为链接签名 30 | func Sign(u *url.URL) error { 31 | return wbiKeys.Sign(u) 32 | } 33 | 34 | // Update 无视过期时间更新 35 | func Update() error { 36 | return wbiKeys.Update() 37 | } 38 | 39 | func Get() (wk WbiKeys, err error) { 40 | if err = wk.update(false); err != nil { 41 | return WbiKeys{}, err 42 | } 43 | return wbiKeys, nil 44 | } 45 | 46 | var wbiKeys WbiKeys 47 | 48 | type WbiKeys struct { 49 | Img string 50 | Sub string 51 | Mixin string 52 | lastUpdateTime time.Time 53 | } 54 | 55 | // Sign 为链接签名 56 | func (wk *WbiKeys) Sign(u *url.URL) (err error) { 57 | if err = wk.update(false); err != nil { 58 | return err 59 | } 60 | 61 | values := u.Query() 62 | 63 | values = removeUnwantedChars(values, '!', '\'', '(', ')', '*') // 必要性存疑? 64 | 65 | values.Set("wts", strconv.FormatInt(time.Now().Unix(), 10)) 66 | 67 | // [url.Values.Encode] 内会对参数排序, 68 | // 且遍历 map 时本身就是无序的 69 | hash := md5.Sum([]byte(values.Encode() + wk.Mixin)) // Calculate w_rid 70 | values.Set("w_rid", hex.EncodeToString(hash[:])) 71 | u.RawQuery = values.Encode() 72 | return nil 73 | } 74 | 75 | // Update 无视过期时间更新 76 | func (wk *WbiKeys) Update() (err error) { 77 | return wk.update(true) 78 | } 79 | 80 | // update 按需更新 81 | func (wk *WbiKeys) update(purge bool) error { 82 | if !purge && time.Since(wk.lastUpdateTime) < time.Hour { 83 | return nil 84 | } 85 | 86 | // 测试下来不用修改 header 也能过 87 | resp, err := http.Get("https://api.bilibili.com/x/web-interface/nav") 88 | if err != nil { 89 | return err 90 | } 91 | defer resp.Body.Close() 92 | body, err := io.ReadAll(resp.Body) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | nav := Nav{} 98 | err = json.Unmarshal(body, &nav) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | if nav.Code != 0 && nav.Code != -101 { // -101 未登录时也会返回两个 key 104 | return fmt.Errorf("unexpected code: %d, message: %s", nav.Code, nav.Message) 105 | } 106 | img := nav.Data.WbiImg.ImgUrl 107 | sub := nav.Data.WbiImg.SubUrl 108 | if img == "" || sub == "" { 109 | return fmt.Errorf("empty image or sub url: %s", body) 110 | } 111 | 112 | // https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png 113 | imgParts := strings.Split(img, "/") 114 | subParts := strings.Split(sub, "/") 115 | 116 | // 7cd084941338484aae1ad9425b84077c.png 117 | imgPng := imgParts[len(imgParts)-1] 118 | subPng := subParts[len(subParts)-1] 119 | 120 | // 7cd084941338484aae1ad9425b84077c 121 | wbiKeys.Img = strings.TrimSuffix(imgPng, ".png") 122 | wbiKeys.Sub = strings.TrimSuffix(subPng, ".png") 123 | 124 | wbiKeys.mixin() 125 | wbiKeys.lastUpdateTime = time.Now() 126 | return nil 127 | } 128 | 129 | func (wk *WbiKeys) mixin() { 130 | var mixin [32]byte 131 | wbi := wk.Img + wk.Sub 132 | for i := range mixin { // for i := 0; i < len(mixin); i++ { 133 | mixin[i] = wbi[mixinKeyEncTab[i]] 134 | } 135 | wk.Mixin = string(mixin[:]) 136 | } 137 | 138 | var mixinKeyEncTab = [...]int{ 139 | 46, 47, 18, 2, 53, 8, 23, 32, 140 | 15, 50, 10, 31, 58, 3, 45, 35, 141 | 27, 43, 5, 49, 33, 9, 42, 19, 142 | 29, 28, 14, 39, 12, 38, 41, 13, 143 | 37, 48, 7, 16, 24, 55, 40, 61, 144 | 26, 17, 0, 1, 60, 51, 30, 4, 145 | 22, 25, 54, 21, 56, 59, 6, 63, 146 | 57, 62, 11, 36, 20, 34, 44, 52, 147 | } 148 | 149 | func removeUnwantedChars(v url.Values, chars ...byte) url.Values { 150 | b := []byte(v.Encode()) 151 | for _, c := range chars { 152 | b = bytes.ReplaceAll(b, []byte{c}, nil) 153 | } 154 | s, err := url.ParseQuery(string(b)) 155 | if err != nil { 156 | panic(err) 157 | } 158 | return s 159 | } 160 | 161 | type Nav struct { 162 | Code int `json:"code"` 163 | Message string `json:"message"` 164 | Ttl int `json:"ttl"` 165 | Data struct { 166 | WbiImg struct { 167 | ImgUrl string `json:"img_url"` 168 | SubUrl string `json:"sub_url"` 169 | } `json:"wbi_img"` 170 | 171 | // ...... 172 | } `json:"data"` 173 | } 174 | -------------------------------------------------------------------------------- /internal/live_room/user_info.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/shr-go/bili_live_tui/api" 8 | "github.com/skip2/go-qrcode" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/cookiejar" 13 | "net/url" 14 | "strings" 15 | ) 16 | 17 | var ( 18 | QRCodeGenerateErr = errors.New("QRCode generate error") 19 | PollLoginError = errors.New("poll login failed") 20 | ) 21 | 22 | func QRCodeLogin(client *http.Client) (data *api.QRCodeLoginData, err error) { 23 | baseURL := "https://passport.bilibili.com/x/passport-login/web/qrcode/generate" 24 | resp, err := client.Get(baseURL) 25 | if err != nil { 26 | return 27 | } 28 | defer resp.Body.Close() 29 | body, err := io.ReadAll(resp.Body) 30 | if err != nil { 31 | return 32 | } 33 | respData := new(api.QRCodeGenerateResp) 34 | if err = json.Unmarshal(body, respData); err != nil || respData.Code != 0 { 35 | err = QRCodeGenerateErr 36 | return 37 | } 38 | q, err := qrcode.New(respData.Data.Url, qrcode.Low) 39 | if err != nil { 40 | return 41 | } 42 | q.WriteFile(256, "login.png") 43 | qrStr := q.ToSmallString(false) 44 | data = &api.QRCodeLoginData{ 45 | QRString: qrStr, 46 | QRKey: respData.Data.QrcodeKey, 47 | Status: api.QRLoginNotScan, 48 | } 49 | return 50 | } 51 | 52 | func PollLogin(client *http.Client, data *api.QRCodeLoginData) (cookie string, err error) { 53 | baseURL := "https://passport.bilibili.com/x/passport-login/web/qrcode/poll" 54 | realURL := fmt.Sprintf("%s?qrcode_key=%s", baseURL, data.QRKey) 55 | resp, err := client.Get(realURL) 56 | if err != nil { 57 | return 58 | } 59 | defer resp.Body.Close() 60 | body, err := io.ReadAll(resp.Body) 61 | if err != nil { 62 | return 63 | } 64 | var pollLogin api.PollLoginResp 65 | err = json.Unmarshal(body, &pollLogin) 66 | if err != nil || pollLogin.Code != 0 { 67 | err = PollLoginError 68 | return 69 | } 70 | data.Status = pollLogin.Data.Code 71 | if data.Status == api.QRLoginSuccess { 72 | sb := strings.Builder{} 73 | cookies := resp.Cookies() 74 | for n, oneCookie := range cookies { 75 | sb.WriteString(oneCookie.Name) 76 | sb.WriteRune('=') 77 | sb.WriteString(oneCookie.Value) 78 | if n+1 != len(cookies) { 79 | sb.WriteString("; ") 80 | } 81 | } 82 | cookie = sb.String() 83 | } 84 | return 85 | } 86 | 87 | func parseCookieStr(client *http.Client, cookies string) { 88 | defer func() { 89 | recover() 90 | }() 91 | jar, _ := cookiejar.New(nil) 92 | elements := strings.Split(cookies, ";") 93 | var cookieSlice []*http.Cookie 94 | for _, element := range elements { 95 | element := strings.TrimSpace(element) 96 | nameValue := strings.Split(element, "=") 97 | cookie := &http.Cookie{ 98 | Name: nameValue[0], 99 | Value: nameValue[1], 100 | Path: "/", 101 | Domain: ".bilibili.com", 102 | } 103 | cookieSlice = append(cookieSlice, cookie) 104 | } 105 | u, _ := url.Parse("https://bilibili.com") 106 | jar.SetCookies(u, cookieSlice) 107 | client.Jar = jar 108 | } 109 | 110 | func CheckCookieValid(client *http.Client, cookie string) bool { 111 | if cookie == "" { 112 | return false 113 | } 114 | parseCookieStr(client, cookie) 115 | return CheckAuth(client) 116 | } 117 | 118 | func CheckAuth(client *http.Client) bool { 119 | baseURL := "https://account.bilibili.com/site/getCoin" 120 | resp, err := client.Get(baseURL) 121 | if err != nil { 122 | return false 123 | } 124 | 125 | defer resp.Body.Close() 126 | respBody, err := ioutil.ReadAll(resp.Body) 127 | if err != nil { 128 | return false 129 | } 130 | var data map[string]interface{} 131 | if err = json.Unmarshal(respBody, &data); err != nil { 132 | return false 133 | } 134 | return data["code"].(float64) == 0 135 | } 136 | 137 | func GetUserInfo(client *http.Client) *api.UserInfo { 138 | baseURL := "https://api.bilibili.com/x/web-interface/nav" 139 | resp, err := client.Get(baseURL) 140 | if err != nil { 141 | return nil 142 | } 143 | defer resp.Body.Close() 144 | respBody, err := ioutil.ReadAll(resp.Body) 145 | if err != nil { 146 | return nil 147 | } 148 | var userInfo api.UserInfo 149 | if err = json.Unmarshal(respBody, &userInfo); err != nil || userInfo.Code != 0 { 150 | return nil 151 | } 152 | return &userInfo 153 | } 154 | 155 | func getCSRF(client *http.Client) string { 156 | u, _ := url.Parse("https://bilibili.com") 157 | cookies := client.Jar.Cookies(u) 158 | for _, cookie := range cookies { 159 | if cookie.Name == "bili_jct" { 160 | return cookie.Value 161 | } 162 | } 163 | return "" 164 | } 165 | -------------------------------------------------------------------------------- /pkg/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "errors" 5 | "go.uber.org/zap" 6 | "go.uber.org/zap/zapcore" 7 | "gopkg.in/natefinch/lumberjack.v2" 8 | ) 9 | 10 | var ( 11 | flushLogs func() error 12 | rotateLogs func() error 13 | defaultLogger Logger 14 | defaultLoggingLevel Level 15 | ) 16 | 17 | type Level = zapcore.Level 18 | 19 | const ( 20 | // DebugLevel logs are typically voluminous, and are usually disabled in 21 | // production. 22 | DebugLevel Level = iota - 1 23 | // InfoLevel is the default logging priority. 24 | InfoLevel 25 | // WarnLevel logs are more important than Info, but don't need individual 26 | // human review. 27 | WarnLevel 28 | // ErrorLevel logs are high-priority. If an application is running smoothly, 29 | // it shouldn't generate any error-level logs. 30 | ErrorLevel 31 | // DPanicLevel logs are particularly important errors. In development the 32 | // logger panics after writing the message. 33 | DPanicLevel 34 | // PanicLevel logs a message, then panics. 35 | PanicLevel 36 | // FatalLevel logs a message, then calls os.Exit(1). 37 | FatalLevel 38 | ) 39 | 40 | // Logger is used for logging formatted messages. 41 | type Logger interface { 42 | // Debugf logs messages at DEBUG level. 43 | Debugf(format string, args ...interface{}) 44 | // Infof logs messages at INFO level. 45 | Infof(format string, args ...interface{}) 46 | // Warnf logs messages at WARN level. 47 | Warnf(format string, args ...interface{}) 48 | // Errorf logs messages at ERROR level. 49 | Errorf(format string, args ...interface{}) 50 | // Fatalf logs messages at FATAL level. 51 | Fatalf(format string, args ...interface{}) 52 | } 53 | 54 | func InitLogConfig() { 55 | //fileName := os.Getenv("LOGGING_FILE") 56 | fileName := "bililive_tui.log" 57 | if len(fileName) > 0 { 58 | var err error 59 | defaultLogger, flushLogs, rotateLogs, err = CreateLoggerAsLocalFile(fileName, defaultLoggingLevel) 60 | if err != nil { 61 | panic("invalid LOGGING_FILE, " + err.Error()) 62 | } 63 | } else { 64 | cfg := zap.NewDevelopmentConfig() 65 | cfg.Level = zap.NewAtomicLevelAt(defaultLoggingLevel) 66 | cfg.EncoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder 67 | zapLogger, _ := cfg.Build(zap.AddCallerSkip(1)) 68 | defaultLogger = zapLogger.Sugar() 69 | } 70 | } 71 | 72 | func getEncoder() zapcore.Encoder { 73 | encoderConfig := zap.NewProductionEncoderConfig() 74 | encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder 75 | encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder 76 | return zapcore.NewConsoleEncoder(encoderConfig) 77 | } 78 | 79 | func GetDefaultLogger() Logger { 80 | return defaultLogger 81 | } 82 | 83 | func LogLevel() string { 84 | return defaultLoggingLevel.String() 85 | } 86 | 87 | func CreateLoggerAsLocalFile(localFilePath string, logLevel Level) (logger Logger, flush func() error, rotate func() error, err error) { 88 | if len(localFilePath) == 0 { 89 | return nil, nil, nil, errors.New("invalid local logger path") 90 | } 91 | 92 | // lumberjack.Logger is already safe for concurrent use, so we don't need to lock it. 93 | lumberJackLogger := &lumberjack.Logger{ 94 | Filename: localFilePath, 95 | MaxSize: 100, // megabytes 96 | } 97 | 98 | encoder := getEncoder() 99 | ws := zapcore.AddSync(lumberJackLogger) 100 | zapcore.Lock(ws) 101 | 102 | levelEnabler := zap.LevelEnablerFunc(func(level Level) bool { 103 | return level >= logLevel 104 | }) 105 | core := zapcore.NewCore(encoder, ws, levelEnabler) 106 | zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) 107 | logger = zapLogger.Sugar() 108 | flush = zapLogger.Sync 109 | rotate = lumberJackLogger.Rotate 110 | return 111 | } 112 | 113 | func Cleanup() { 114 | if flushLogs != nil { 115 | _ = flushLogs() 116 | } 117 | } 118 | 119 | func Rotate() { 120 | if rotateLogs != nil { 121 | _ = rotateLogs() 122 | } 123 | } 124 | 125 | func Error(err error) { 126 | if err != nil { 127 | defaultLogger.Errorf("error occurs during runtime, %v", err) 128 | } 129 | } 130 | 131 | func Debugf(format string, args ...interface{}) { 132 | defaultLogger.Debugf(format, args...) 133 | } 134 | 135 | func Infof(format string, args ...interface{}) { 136 | defaultLogger.Infof(format, args...) 137 | } 138 | 139 | func Warnf(format string, args ...interface{}) { 140 | defaultLogger.Warnf(format, args...) 141 | } 142 | 143 | func Errorf(format string, args ...interface{}) { 144 | defaultLogger.Errorf(format, args...) 145 | } 146 | 147 | func Fatalf(format string, args ...interface{}) { 148 | defaultLogger.Fatalf(format, args...) 149 | } 150 | -------------------------------------------------------------------------------- /internal/tui/widget.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var ( 10 | getColor = func(color string, overrideAsBlack ...bool) lipgloss.Color { 11 | if !LiveConfig.ColorMode { 12 | if len(overrideAsBlack) > 0 && overrideAsBlack[0] { 13 | return lipgloss.Color("#3C3C3C") 14 | } 15 | return lipgloss.Color("#FAFAFA") 16 | } 17 | return lipgloss.Color(color) 18 | } 19 | 20 | shipLevelToString = map[uint8]string{ 21 | 0: "", 22 | 1: "总", 23 | 2: "提", 24 | 3: "舰", 25 | } 26 | medalStyle = func(medal *medalInfo) string { 27 | shipString := shipLevelToString[medal.shipLevel] 28 | if shipString != "" { 29 | shipString = lipgloss.NewStyle(). 30 | Foreground(getColor("#F87299")). 31 | Render(shipString) 32 | } 33 | nameString := lipgloss.NewStyle(). 34 | MaxWidth(10). 35 | Align(lipgloss.Center). 36 | Foreground(getColor("#FAFAFA")). 37 | Background(getColor(medal.medalColor, true)). 38 | Render(medal.name) 39 | levelString := lipgloss.NewStyle(). 40 | Width(2). 41 | Align(lipgloss.Right). 42 | Foreground(getColor("#3C3C3C")). 43 | Background(getColor("#FAFAFA", true)). 44 | Render(strconv.Itoa(int(medal.level))) 45 | if !LiveConfig.ShowMedalLevel { 46 | levelString = "" 47 | } 48 | if !LiveConfig.ShowMedalName { 49 | nameString = "" 50 | } 51 | if !LiveConfig.ShowShipLevel { 52 | shipString = "" 53 | } 54 | if LiveConfig.ShowMedalLevel || LiveConfig.ShowMedalName || LiveConfig.ShowShipLevel { 55 | return shipString + nameString + levelString + " " 56 | } else { 57 | return shipString + nameString + levelString 58 | } 59 | } 60 | 61 | nameStyle = func(name string, nameColor string) string { 62 | if nameColor == "" { 63 | nameColor = "#FAFAFA" 64 | } 65 | return lipgloss.NewStyle(). 66 | Foreground(getColor(nameColor)). 67 | Render(name + ":") 68 | } 69 | 70 | contentStyle = func(content string, contentColor string) string { 71 | if contentColor == "" { 72 | contentColor = "#FAFAFA" 73 | } 74 | return lipgloss.NewStyle(). 75 | Foreground(getColor(contentColor)). 76 | Render(content) 77 | } 78 | ) 79 | 80 | var ( 81 | subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} 82 | highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 83 | special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} 84 | 85 | divider = lipgloss.NewStyle(). 86 | SetString("•"). 87 | Padding(0, 1). 88 | Foreground(subtle). 89 | String() 90 | 91 | urlStyle = lipgloss.NewStyle().Foreground(special).Render 92 | 93 | activeTabBorder = lipgloss.Border{ 94 | Top: "─", 95 | Bottom: " ", 96 | Left: "│", 97 | Right: "│", 98 | TopLeft: "╭", 99 | TopRight: "╮", 100 | BottomLeft: "┘", 101 | BottomRight: "└", 102 | } 103 | 104 | tabBorder = lipgloss.Border{ 105 | Top: "─", 106 | Bottom: "─", 107 | Left: "│", 108 | Right: "│", 109 | TopLeft: "╭", 110 | TopRight: "╮", 111 | BottomLeft: "┴", 112 | BottomRight: "┴", 113 | } 114 | 115 | tab = lipgloss.NewStyle(). 116 | Border(tabBorder, true). 117 | BorderForeground(highlight). 118 | Padding(0, 1) 119 | 120 | activeTab = tab.Copy().Border(activeTabBorder, true) 121 | 122 | tabGap = tab.Copy(). 123 | BorderTop(false). 124 | BorderLeft(false). 125 | BorderRight(false) 126 | 127 | titleStyle = lipgloss.NewStyle(). 128 | MarginLeft(1). 129 | MarginRight(5). 130 | Padding(0, 1). 131 | Italic(true). 132 | Foreground(getColor("#FFF7DB")). 133 | SetString("Lip Gloss") 134 | 135 | descStyle = lipgloss.NewStyle().MarginTop(1) 136 | 137 | infoStyle = lipgloss.NewStyle(). 138 | BorderStyle(lipgloss.NormalBorder()). 139 | BorderTop(true). 140 | BorderForeground(subtle) 141 | 142 | dialogBoxStyle = lipgloss.NewStyle(). 143 | Border(lipgloss.RoundedBorder()). 144 | BorderForeground(getColor("#874BFD")). 145 | Padding(1, 0). 146 | BorderTop(true). 147 | BorderLeft(true). 148 | BorderRight(true). 149 | BorderBottom(true) 150 | 151 | buttonStyle = lipgloss.NewStyle(). 152 | Foreground(getColor("#FFF7DB")). 153 | Background(getColor("#888B7E")). 154 | Padding(0, 3). 155 | MarginTop(1) 156 | 157 | activeButtonStyle = buttonStyle.Copy(). 158 | Foreground(getColor("#FFF7DB")). 159 | Background(getColor("#F25D94")). 160 | Underline(true) 161 | 162 | listStyle = lipgloss.NewStyle(). 163 | Border(lipgloss.NormalBorder(), false, true, false, false). 164 | BorderForeground(subtle). 165 | MarginRight(2) 166 | 167 | listHeader = lipgloss.NewStyle(). 168 | BorderStyle(lipgloss.NormalBorder()). 169 | BorderBottom(true). 170 | BorderForeground(subtle). 171 | MarginRight(2). 172 | Render 173 | 174 | listItem = lipgloss.NewStyle().PaddingLeft(2).Render 175 | 176 | checkMark = lipgloss.NewStyle().SetString("✓"). 177 | Foreground(special). 178 | PaddingRight(1). 179 | String() 180 | 181 | listDone = func(s string) string { 182 | return checkMark + lipgloss.NewStyle(). 183 | Strikethrough(true). 184 | Foreground(lipgloss.AdaptiveColor{Light: "#969B86", Dark: "#696969"}). 185 | Render(s) 186 | } 187 | 188 | statusNugget = lipgloss.NewStyle(). 189 | Foreground(getColor("#FFFDF5")). 190 | Padding(0, 1) 191 | 192 | statusBarStyle = lipgloss.NewStyle(). 193 | Foreground(lipgloss.AdaptiveColor{Light: "#343433", Dark: "#C1C6B2"}). 194 | Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"}) 195 | 196 | statusStyle = lipgloss.NewStyle(). 197 | Inherit(statusBarStyle). 198 | Foreground(getColor("#FFFDF5")). 199 | Background(getColor("#FF5F87")). 200 | Padding(0, 1). 201 | MarginRight(1) 202 | 203 | encodingStyle = statusNugget.Copy(). 204 | Background(getColor("#A550DF")). 205 | Align(lipgloss.Right) 206 | 207 | statusText = lipgloss.NewStyle().Inherit(statusBarStyle) 208 | 209 | fishCakeStyle = statusNugget.Copy().Background(getColor("#6124DF")) 210 | 211 | // Page. 212 | 213 | docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) 214 | 215 | focusedStyle = lipgloss.NewStyle(). 216 | BorderStyle(lipgloss.NormalBorder()). 217 | BorderForeground(getColor("69")) 218 | 219 | unFocusedStyle = lipgloss.NewStyle(). 220 | BorderStyle(lipgloss.HiddenBorder()) 221 | ) 222 | -------------------------------------------------------------------------------- /internal/tui/login_model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/shr-go/bili_live_tui/api" 7 | "github.com/shr-go/bili_live_tui/internal/live_room" 8 | "github.com/shr-go/bili_live_tui/pkg/logging" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | type loginStep uint8 15 | 16 | const ( 17 | loginStepConfirmLogin loginStep = iota 18 | loginStepWaitLogin 19 | loginStepLoginNeedRefresh 20 | loginStepLoginSuccess 21 | loginStepDone 22 | ) 23 | 24 | type loginModel struct { 25 | step loginStep 26 | client *http.Client 27 | loginData *api.QRCodeLoginData 28 | room *api.LiveRoom 29 | cookies string 30 | chooseLogin bool 31 | localCookie bool 32 | quit bool 33 | } 34 | 35 | func newLoginModel(client *http.Client) loginModel { 36 | return loginModel{ 37 | step: loginStepConfirmLogin, 38 | client: client, 39 | loginData: nil, 40 | room: nil, 41 | cookies: "", 42 | chooseLogin: true, 43 | localCookie: false, 44 | quit: false, 45 | } 46 | } 47 | 48 | type waitScanMsg struct{} 49 | 50 | type TickMsg time.Time 51 | 52 | func (m *loginModel) loadLoginData() tea.Msg { 53 | loginData, err := live_room.QRCodeLogin(m.client) 54 | if err != nil { 55 | logging.Fatalf("loadLoginData failed, err=%v", err) 56 | } 57 | m.loginData = loginData 58 | return waitScanMsg{} 59 | } 60 | 61 | func tickEvery() tea.Cmd { 62 | return tea.Every(time.Second, func(t time.Time) tea.Msg { 63 | return TickMsg(t) 64 | }) 65 | } 66 | 67 | func (m *loginModel) pollLoginStatus() tea.Msg { 68 | cookies, err := live_room.PollLogin(m.client, m.loginData) 69 | if err != nil { 70 | logging.Fatalf("pollLoginStatus failed, err=%v", err) 71 | } 72 | switch m.loginData.Status { 73 | case api.QRLoginExpired: 74 | m.step = loginStepLoginNeedRefresh 75 | case api.QRLoginSuccess: 76 | m.step = loginStepLoginSuccess 77 | m.cookies = cookies 78 | } 79 | return m.step 80 | } 81 | 82 | func (m *loginModel) enterRoom() tea.Msg { 83 | if m.chooseLogin && !m.localCookie { 84 | if !live_room.CheckCookieValid(m.client, m.cookies) { 85 | logging.Fatalf("PrepareEnterRoom cookies check failed, program exit") 86 | } 87 | os.WriteFile("COOKIE.DAT", []byte(m.cookies), 0o660) 88 | } 89 | 90 | if room, err := live_room.AuthAndConnect(m.client, LiveConfig.RoomID); err != nil { 91 | logging.Fatalf("AuthAndConnect failed, err=%v", err) 92 | } else { 93 | m.room = room 94 | } 95 | m.step = loginStepDone 96 | return m.step 97 | } 98 | 99 | func (m *loginModel) Init() tea.Cmd { 100 | if m.step == loginStepLoginSuccess { 101 | return m.enterRoom 102 | } 103 | return nil 104 | } 105 | 106 | func (m *loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 107 | switch msg := msg.(type) { 108 | case tea.KeyMsg: 109 | if msg.String() == "ctrl+c" { 110 | m.quit = true 111 | return m, tea.Quit 112 | } 113 | switch m.step { 114 | case loginStepConfirmLogin: 115 | switch msg.String() { 116 | case "tab": 117 | m.chooseLogin = !m.chooseLogin 118 | case "left": 119 | m.chooseLogin = true 120 | case "right": 121 | m.chooseLogin = false 122 | case "enter", " ": 123 | if m.chooseLogin { 124 | m.step = loginStepWaitLogin 125 | return m, m.loadLoginData 126 | } else { 127 | m.step = loginStepLoginSuccess 128 | return m, m.enterRoom 129 | } 130 | } 131 | case loginStepLoginNeedRefresh: 132 | if msg.String() == "enter" || msg.String() == " " { 133 | m.step = loginStepWaitLogin 134 | m.loginData = nil 135 | return m, m.loadLoginData 136 | } 137 | } 138 | return m, nil 139 | case waitScanMsg: 140 | return m, tickEvery() 141 | case TickMsg: 142 | return m, m.pollLoginStatus 143 | case loginStep: 144 | if msg == loginStepWaitLogin { 145 | return m, tickEvery() 146 | } else if msg == loginStepLoginSuccess { 147 | return m, m.enterRoom 148 | } else if msg == loginStepDone { 149 | return m, tea.Quit 150 | } 151 | } 152 | return m, nil 153 | } 154 | 155 | func (m *loginModel) View() string { 156 | switch m.step { 157 | case loginStepConfirmLogin: 158 | var loginButton, cancelButton string 159 | if m.chooseLogin { 160 | loginButton = activeButtonStyle.Render("扫码登录") 161 | cancelButton = buttonStyle.Render("取消") 162 | } else { 163 | loginButton = buttonStyle.Render("扫码登录") 164 | cancelButton = activeButtonStyle.Render("取消") 165 | } 166 | 167 | question := lipgloss.NewStyle().Width(50).Align(lipgloss.Center). 168 | Render("扫码登陆后才能发送弹幕哦!") 169 | buttons := lipgloss.JoinHorizontal(lipgloss.Top, loginButton, " ", cancelButton) 170 | ui := lipgloss.JoinVertical(lipgloss.Center, question, buttons) 171 | dialog := lipgloss.Place(windowWidth, windowHeight, 172 | lipgloss.Center, lipgloss.Center, 173 | dialogBoxStyle.Render(ui), 174 | lipgloss.WithWhitespaceForeground(subtle), 175 | ) 176 | return dialog 177 | case loginStepWaitLogin: 178 | if m.loginData != nil { 179 | tips := "扫描下方的二维码或软件目录内的login.png完成登录" 180 | if m.loginData.Status == api.QRLoginNotConfirm { 181 | tips = "请在手机上点击确定完成登录" 182 | } 183 | ui := lipgloss.JoinVertical(lipgloss.Center, tips, m.loginData.QRString) 184 | dialogBoxStyleCopy := dialogBoxStyle.Copy().Padding(0, 0) 185 | return lipgloss.Place(windowWidth, windowHeight, 186 | lipgloss.Center, lipgloss.Center, 187 | dialogBoxStyleCopy.Render(ui), 188 | lipgloss.WithWhitespaceForeground(subtle), 189 | ) 190 | } 191 | case loginStepLoginNeedRefresh: 192 | question := lipgloss.NewStyle().Width(50).Align(lipgloss.Center). 193 | Render("二维码已过期,请刷新后再试") 194 | confirmButton := activeButtonStyle.Render("刷新") 195 | ui := lipgloss.JoinVertical(lipgloss.Center, question, confirmButton) 196 | return lipgloss.Place(windowWidth, windowHeight, 197 | lipgloss.Center, lipgloss.Center, 198 | dialogBoxStyle.Render(ui), 199 | lipgloss.WithWhitespaceForeground(subtle), 200 | ) 201 | case loginStepLoginSuccess: 202 | str := "登陆成功,正在连接服务器" 203 | if !m.chooseLogin { 204 | str = "以游客身份登录,正在连接服务器" 205 | } 206 | text := lipgloss.NewStyle().Width(50).Align(lipgloss.Center).Render(str) 207 | dialog := lipgloss.Place(windowWidth, windowHeight, 208 | lipgloss.Center, lipgloss.Center, 209 | dialogBoxStyle.Render(text), 210 | lipgloss.WithWhitespaceForeground(subtle), 211 | ) 212 | return dialog 213 | } 214 | return "" 215 | } 216 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= 2 | github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 4 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 8 | github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og= 9 | github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 10 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 11 | github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24= 12 | github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= 13 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 14 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 15 | github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= 16 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 17 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 18 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 23 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 24 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 25 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 26 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 27 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 28 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 29 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 30 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 31 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 32 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 33 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 34 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 35 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 36 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 37 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 38 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 39 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 40 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 41 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 42 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 43 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 44 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 45 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 46 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 47 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 48 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 49 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 53 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 54 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 55 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 56 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 57 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 58 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 60 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 61 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 62 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 63 | go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= 64 | go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= 65 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 66 | go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= 67 | go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= 68 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 69 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 74 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 76 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 77 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 78 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 79 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 80 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 81 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 82 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= 85 | gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= 86 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 87 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | -------------------------------------------------------------------------------- /api/live_room.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | ) 7 | 8 | type LiveRoom struct { 9 | UID uint64 10 | RoomID uint64 11 | Hot uint32 12 | Seq uint32 13 | MessageChan chan *DanmuMessage 14 | ReqChan chan []byte 15 | DoneChan chan struct{} 16 | RetryChan chan struct{} 17 | StreamConn net.Conn 18 | Title string 19 | ShortID uint64 20 | OwnerId uint64 21 | RoomUserInfo *UserRoomProperty 22 | Client *http.Client 23 | CSRF string 24 | } 25 | 26 | type DanmuInfoReq struct { 27 | ID uint64 `url:"id"` 28 | } 29 | 30 | type DanmuInfoResp struct { 31 | Code int `json:"code"` 32 | Message string `json:"message"` 33 | Ttl int `json:"ttl"` 34 | Data struct { 35 | Group string `json:"group"` 36 | BusinessId int `json:"business_id"` 37 | RefreshRowFactor float64 `json:"refresh_row_factor"` 38 | RefreshRate int `json:"refresh_rate"` 39 | MaxDelay int `json:"max_delay"` 40 | Token string `json:"token"` 41 | HostList []struct { 42 | Host string `json:"host"` 43 | Port int `json:"port"` 44 | WssPort int `json:"wss_port"` 45 | WsPort int `json:"ws_port"` 46 | } `json:"host_list"` 47 | } `json:"data"` 48 | } 49 | 50 | type RoomInfoReq struct { 51 | RoomID uint64 `url:"room_id"` 52 | } 53 | 54 | type RoomInfoResp struct { 55 | Code int `json:"code"` 56 | Msg string `json:"msg"` 57 | Message string `json:"message"` 58 | Data struct { 59 | Uid int `json:"uid"` 60 | RoomId int `json:"room_id"` 61 | ShortId int `json:"short_id"` 62 | Attention int `json:"attention"` 63 | Online int `json:"online"` 64 | IsPortrait bool `json:"is_portrait"` 65 | Description string `json:"description"` 66 | LiveStatus int `json:"live_status"` 67 | AreaId int `json:"area_id"` 68 | ParentAreaId int `json:"parent_area_id"` 69 | ParentAreaName string `json:"parent_area_name"` 70 | OldAreaId int `json:"old_area_id"` 71 | Background string `json:"background"` 72 | Title string `json:"title"` 73 | UserCover string `json:"user_cover"` 74 | Keyframe string `json:"keyframe"` 75 | IsStrictRoom bool `json:"is_strict_room"` 76 | LiveTime string `json:"live_time"` 77 | Tags string `json:"tags"` 78 | IsAnchor int `json:"is_anchor"` 79 | RoomSilentType string `json:"room_silent_type"` 80 | RoomSilentLevel int `json:"room_silent_level"` 81 | RoomSilentSecond int `json:"room_silent_second"` 82 | AreaName string `json:"area_name"` 83 | Pendants string `json:"pendants"` 84 | AreaPendants string `json:"area_pendants"` 85 | HotWords []string `json:"hot_words"` 86 | HotWordsStatus int `json:"hot_words_status"` 87 | Verify string `json:"verify"` 88 | NewPendants struct { 89 | Frame struct { 90 | Name string `json:"name"` 91 | Value string `json:"value"` 92 | Position int `json:"position"` 93 | Desc string `json:"desc"` 94 | Area int `json:"area"` 95 | AreaOld int `json:"area_old"` 96 | BgColor string `json:"bg_color"` 97 | BgPic string `json:"bg_pic"` 98 | UseOldArea bool `json:"use_old_area"` 99 | } `json:"frame"` 100 | Badge struct { 101 | Name string `json:"name"` 102 | Position int `json:"position"` 103 | Value string `json:"value"` 104 | Desc string `json:"desc"` 105 | } `json:"badge"` 106 | MobileFrame struct { 107 | Name string `json:"name"` 108 | Value string `json:"value"` 109 | Position int `json:"position"` 110 | Desc string `json:"desc"` 111 | Area int `json:"area"` 112 | AreaOld int `json:"area_old"` 113 | BgColor string `json:"bg_color"` 114 | BgPic string `json:"bg_pic"` 115 | UseOldArea bool `json:"use_old_area"` 116 | } `json:"mobile_frame"` 117 | MobileBadge interface{} `json:"mobile_badge"` 118 | } `json:"new_pendants"` 119 | UpSession string `json:"up_session"` 120 | PkStatus int `json:"pk_status"` 121 | PkId int `json:"pk_id"` 122 | BattleId int `json:"battle_id"` 123 | AllowChangeAreaTime int `json:"allow_change_area_time"` 124 | AllowUploadCoverTime int `json:"allow_upload_cover_time"` 125 | StudioInfo struct { 126 | Status int `json:"status"` 127 | MasterList []interface{} `json:"master_list"` 128 | } `json:"studio_info"` 129 | } `json:"data"` 130 | } 131 | 132 | type DanmuAuthPacketReq struct { 133 | UID uint64 `json:"uid"` 134 | RoomID uint64 `json:"roomid"` 135 | ProtoVer uint8 `json:"protover"` 136 | Platform string `json:"platform"` 137 | Type uint8 `json:"type"` 138 | Key string `json:"key"` 139 | } 140 | 141 | type DanmuProtol uint16 142 | 143 | const ( 144 | DanmuProtolNormal DanmuProtol = iota 145 | DanmuProtolHeartBeat 146 | DanmuProtolNormalZlib 147 | DanmuProtolNormalBrotli 148 | ) 149 | 150 | type DanmuOp uint32 151 | 152 | const ( 153 | DanmuOpHeartBeat DanmuOp = 2 154 | DanmuOpHeartBeatResp DanmuOp = 3 155 | DanmuOpNormal DanmuOp = 5 156 | DanmuOpAuth DanmuOp = 7 157 | DanmuOpAuthResp DanmuOp = 8 158 | ) 159 | 160 | type DanmuAuthPacketResp struct { 161 | Code uint32 `json:"code"` 162 | } 163 | 164 | type DanmuMessageHeader struct { 165 | Size uint32 166 | HeaderSize uint16 167 | ProtoVer DanmuProtol 168 | OpCode DanmuOp 169 | Sequence uint32 170 | } 171 | 172 | type DanmuMessage struct { 173 | Cmd string `json:"cmd"` 174 | Info []interface{} `json:"info"` 175 | Data map[string]interface{} `json:"data"` 176 | Other map[string]interface{} `json:"-"` 177 | } 178 | 179 | type QRCodeGenerateResp struct { 180 | Code int `json:"code"` 181 | Message string `json:"message"` 182 | Ttl int `json:"ttl"` 183 | Data struct { 184 | Url string `json:"url"` 185 | QrcodeKey string `json:"qrcode_key"` 186 | } `json:"data"` 187 | } 188 | 189 | type QRLoginStatus uint32 190 | 191 | const ( 192 | QRLoginSuccess QRLoginStatus = 0 193 | QRLoginNotConfirm QRLoginStatus = 86090 194 | QRLoginNotScan QRLoginStatus = 86101 195 | QRLoginExpired QRLoginStatus = 86038 196 | ) 197 | 198 | type QRCodeLoginData struct { 199 | QRString string 200 | QRKey string 201 | Status QRLoginStatus 202 | } 203 | 204 | type PollLoginResp struct { 205 | Code int `json:"code"` 206 | Message string `json:"message"` 207 | Ttl int `json:"ttl"` 208 | Data struct { 209 | Url string `json:"url"` 210 | RefreshToken string `json:"refresh_token"` 211 | Timestamp int64 `json:"timestamp"` 212 | Code QRLoginStatus `json:"code"` 213 | Message string `json:"message"` 214 | } `json:"data"` 215 | } 216 | 217 | type UserInfo struct { 218 | Code int `json:"code"` 219 | Message string `json:"message"` 220 | Ttl int `json:"ttl"` 221 | Data struct { 222 | Mid uint64 `json:"mid"` 223 | Uname string `json:"uname"` 224 | } `json:"data"` 225 | } 226 | 227 | type UserRoomInfo struct { 228 | Code int `json:"code"` 229 | Message string `json:"message"` 230 | Ttl int `json:"ttl"` 231 | Data struct { 232 | Property UserRoomProperty `json:"property"` 233 | } `json:"data"` 234 | } 235 | 236 | type UserRoomProperty struct { 237 | UnameColor string `json:"uname_color"` 238 | Bubble int `json:"bubble"` 239 | Danmu struct { 240 | Mode int `json:"mode"` 241 | Color int `json:"color"` 242 | Length int `json:"length"` 243 | RoomId int `json:"room_id"` 244 | } `json:"danmu"` 245 | BubbleColor string `json:"bubble_color"` 246 | } 247 | 248 | type SendMsgReq struct { 249 | Bubble int `url:"bubble"` 250 | Msg string `url:"msg"` 251 | Color int `url:"color"` 252 | Mode int `url:"mode"` 253 | Fontsize int `url:"fontsize"` 254 | Rnd int64 `url:"rnd"` 255 | RoomID uint64 `url:"roomid"` 256 | CSRF string `url:"csrf"` 257 | CSRFToken string `url:"csrf_token"` 258 | } 259 | -------------------------------------------------------------------------------- /internal/tui/model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "container/list" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | "github.com/charmbracelet/bubbles/textinput" 14 | "github.com/charmbracelet/bubbles/viewport" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/shr-go/bili_live_tui/api" 18 | "github.com/shr-go/bili_live_tui/pkg/logging" 19 | "golang.org/x/term" 20 | ) 21 | 22 | type sessionState uint 23 | 24 | const ( 25 | focusMarginHeight = 1 26 | focusMarginWidth = 1 27 | contentView sessionState = iota 28 | inputView 29 | ) 30 | 31 | type medalInfo struct { 32 | level uint8 33 | shipLevel uint8 34 | name string 35 | medalColor string 36 | } 37 | 38 | type danmuMsg struct { 39 | uid uint64 40 | uName string 41 | chatTime time.Time 42 | content string 43 | medal *medalInfo 44 | nameColor string 45 | contentColor string 46 | } 47 | 48 | type model struct { 49 | danmu *list.List 50 | room *api.LiveRoom 51 | viewport viewport.Model 52 | textInput textinput.Model 53 | ready bool 54 | lockBottom bool 55 | state sessionState 56 | } 57 | 58 | func InitialModel(room *api.LiveRoom) model { 59 | ti := textinput.New() 60 | ti.CharLimit = 20 61 | 62 | return model{ 63 | danmu: list.New(), 64 | room: room, 65 | viewport: viewport.Model{}, 66 | textInput: ti, 67 | ready: false, 68 | lockBottom: true, 69 | state: contentView, 70 | } 71 | } 72 | 73 | func (m model) sendDanmu(needSend string) tea.Cmd { 74 | if m.room.RoomUserInfo == nil { 75 | danmu := generateFakeDanmuMsg(needSend) 76 | return func() tea.Msg { 77 | return danmu 78 | } 79 | } else { 80 | danmu := generateDanmuMsg(needSend, m.room) 81 | return func() tea.Msg { 82 | contentType, form := packDanmuMsgForm(danmu) 83 | baseURL := "https://api.live.bilibili.com/msg/send" 84 | resp, err := m.room.Client.Post(baseURL, contentType, form) 85 | if err != nil { 86 | logging.Errorf("Send Danmu failed, err=%v", err) 87 | return nil 88 | } 89 | defer resp.Body.Close() 90 | respBody, err := ioutil.ReadAll(resp.Body) 91 | var data map[string]interface{} 92 | if err = json.Unmarshal(respBody, &data); err != nil || data["code"].(float64) != 0 { 93 | logging.Errorf("Send Danmu failed, err=%v, data=%v", err, data) 94 | return nil 95 | } 96 | return nil 97 | } 98 | } 99 | } 100 | 101 | func (m model) Init() tea.Cmd { 102 | return nil 103 | } 104 | 105 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 106 | var ( 107 | cmd tea.Cmd 108 | cmds []tea.Cmd 109 | ) 110 | switch msg := msg.(type) { 111 | case tea.KeyMsg: 112 | switch msg.String() { 113 | case "ctrl+c": 114 | return m, tea.Quit 115 | case "tab": 116 | if m.state == contentView { 117 | m.state = inputView 118 | cmd = m.textInput.Focus() 119 | cmds = append(cmds, cmd) 120 | } else if m.state == inputView { 121 | m.state = contentView 122 | m.textInput.Blur() 123 | } 124 | case "enter": 125 | if m.state == inputView { 126 | needSend := m.textInput.Value() 127 | m.textInput.Reset() 128 | if len(needSend) > 0 { 129 | cmd = m.sendDanmu(needSend) 130 | cmds = append(cmds, cmd) 131 | } 132 | } 133 | } 134 | case tea.WindowSizeMsg: 135 | headerHeight := lipgloss.Height(m.headerView()) + focusMarginHeight 136 | footerHeight := lipgloss.Height(m.footerView()) + lipgloss.Height(m.textInput.View()) + 3*focusMarginHeight 137 | verticalMarginHeight := headerHeight + footerHeight 138 | verticalMarginWidth := 2 * focusMarginWidth 139 | 140 | if !m.ready { 141 | m.viewport = viewport.New(msg.Width-verticalMarginWidth, msg.Height-verticalMarginHeight) 142 | m.viewport.YPosition = headerHeight 143 | m.viewport.HighPerformanceRendering = false 144 | m.viewport.SetContent(m.renderDanmu()) 145 | m.ready = true 146 | } else { 147 | m.viewport.Width = msg.Width - verticalMarginWidth 148 | m.viewport.Height = msg.Height - verticalMarginHeight 149 | } 150 | textWieth := msg.Width - verticalMarginWidth - 3 151 | m.textInput.Placeholder = lipgloss.NewStyle().Width(textWieth).Render("Press Enter to Send") 152 | m.textInput.Width = textWieth 153 | case *danmuMsg: 154 | m.danmu.PushBack(msg) 155 | for m.danmu.Len() > LiveConfig.ChatBuffer { 156 | m.danmu.Remove(m.danmu.Front()) 157 | } 158 | if m.ready { 159 | m.viewport.SetContent(m.renderDanmu()) 160 | } 161 | } 162 | 163 | if m.lockBottom { 164 | m.viewport.GotoBottom() 165 | } 166 | 167 | // if focus isn't on contentView, only mouse can be capture by viewport 168 | if _, msgIsMouse := msg.(tea.MouseMsg); m.state == contentView || msgIsMouse { 169 | scrollPercent := m.viewport.ScrollPercent() 170 | m.viewport, cmd = m.viewport.Update(msg) 171 | cmds = append(cmds, cmd) 172 | newScrollPercent := m.viewport.ScrollPercent() 173 | 174 | if scrollPercent != newScrollPercent { 175 | m.lockBottom = newScrollPercent == 1 176 | } 177 | } 178 | 179 | if _, msgIsMouse := msg.(tea.MouseMsg); m.state == inputView && !msgIsMouse { 180 | m.textInput, cmd = m.textInput.Update(msg) 181 | cmds = append(cmds, cmd) 182 | } 183 | 184 | return m, tea.Batch(cmds...) 185 | } 186 | 187 | func (m model) View() string { 188 | if !m.ready { 189 | return "\nInitializing..." 190 | } 191 | var s string 192 | contentStr := fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) 193 | textStr := m.textInput.View() 194 | if m.state == contentView { 195 | s = lipgloss.JoinVertical(lipgloss.Left, focusedStyle.Render(contentStr), unFocusedStyle.Render(textStr)) 196 | } else { 197 | s = lipgloss.JoinVertical(lipgloss.Left, unFocusedStyle.Render(contentStr), focusedStyle.Render(textStr)) 198 | } 199 | return s 200 | } 201 | 202 | func ReceiveMsg(program *tea.Program, room *api.LiveRoom) { 203 | for msg := range room.MessageChan { 204 | switch msg.Cmd { 205 | case "DANMU_MSG": // 普通弹幕消息 206 | if danmu := processDanmuMsg(msg); danmu != nil { 207 | program.Send(danmu) 208 | } 209 | case "INTERACT_WORD": // 普通进场消息 210 | 211 | case "ENTRY_EFFECT": // 特效进场消息 和上面的普通进场消息存在其一 212 | 213 | case "PREPARING": // 直播结束,这里断一下日志 214 | logging.Rotate() 215 | } 216 | } 217 | } 218 | 219 | func PoolWindowSize(program *tea.Program) { 220 | if runtime.GOOS != "windows" { 221 | return 222 | } 223 | width, height, _ := term.GetSize(int(os.Stdout.Fd())) 224 | for range time.Tick(20 * time.Millisecond) { 225 | nowWidth, nowHeight, _ := term.GetSize(int(os.Stdout.Fd())) 226 | if width != nowWidth || height != nowHeight { 227 | width = nowWidth 228 | height = nowHeight 229 | windowSize := tea.WindowSizeMsg{ 230 | Width: width, 231 | Height: height, 232 | } 233 | program.Send(windowSize) 234 | } 235 | } 236 | } 237 | 238 | func (m model) headerView() string { 239 | b := lipgloss.RoundedBorder() 240 | b.Right = "├" 241 | roomID := m.room.ShortID 242 | if roomID == 0 { 243 | roomID = m.room.RoomID 244 | } 245 | 246 | if !LiveConfig.ShowRoomTitle && !LiveConfig.ShowRoomNumber { 247 | return "" 248 | } 249 | 250 | var header string 251 | // 热度好像已经没有了,先去掉了 252 | if LiveConfig.ShowRoomTitle { 253 | if LiveConfig.ShowRoomNumber { 254 | header = fmt.Sprintf("%s - %d", m.room.Title, roomID) 255 | } else { 256 | header = fmt.Sprintf(m.room.Title) 257 | } 258 | } else { 259 | if LiveConfig.ShowRoomNumber { 260 | header = fmt.Sprintf("%d", roomID) 261 | } 262 | } 263 | 264 | title := lipgloss.NewStyle().BorderStyle(b).Padding(0, 1). 265 | Render(header) 266 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) 267 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 268 | } 269 | 270 | func (m model) footerView() string { 271 | info := lipgloss.NewStyle().Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 272 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 273 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 274 | } 275 | 276 | func (m model) renderDanmu() string { 277 | sb := strings.Builder{} 278 | viewportHeight := m.viewport.Height 279 | for n := m.danmu.Len(); n < viewportHeight; n++ { 280 | sb.WriteRune('\n') 281 | } 282 | for danmuElem := m.danmu.Front(); danmuElem != nil; danmuElem = danmuElem.Next() { 283 | danmu, ok := danmuElem.Value.(*danmuMsg) 284 | if ok { 285 | if danmu.medal != nil { 286 | sb.WriteString(medalStyle(danmu.medal)) 287 | } 288 | sb.WriteString(fmt.Sprintln(nameStyle(danmu.uName, danmu.nameColor), 289 | contentStyle(danmu.content, danmu.contentColor))) 290 | } 291 | } 292 | return sb.String() 293 | } 294 | -------------------------------------------------------------------------------- /internal/live_room/message_stream.go: -------------------------------------------------------------------------------- 1 | package live_room 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "compress/zlib" 7 | "container/list" 8 | "encoding/binary" 9 | "encoding/hex" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "net" 16 | "net/http" 17 | "reflect" 18 | "strconv" 19 | "sync/atomic" 20 | "time" 21 | 22 | "github.com/andybalholm/brotli" 23 | "github.com/google/go-querystring/query" 24 | "github.com/shr-go/bili_live_tui/api" 25 | "github.com/shr-go/bili_live_tui/pkg/logging" 26 | ) 27 | 28 | var ( 29 | invalidMessageErr = errors.New("message invalid") 30 | headerNotCompleteErr = errors.New("header not complete") 31 | ) 32 | 33 | func packMessage(data []byte, protoVer api.DanmuProtol, protoOp api.DanmuOp, seq uint32) []byte { 34 | b := bytes.Buffer{} 35 | length := uint32(16 + len(data)) 36 | binary.Write(&b, binary.BigEndian, length) 37 | binary.Write(&b, binary.BigEndian, uint16(16)) 38 | binary.Write(&b, binary.BigEndian, uint16(protoVer)) 39 | binary.Write(&b, binary.BigEndian, uint32(protoOp)) 40 | binary.Write(&b, binary.BigEndian, seq) 41 | 42 | if len(data) > 0 { 43 | switch protoVer { 44 | case api.DanmuProtolNormalZlib: 45 | w := zlib.NewWriter(&b) 46 | w.Write(data) 47 | w.Close() 48 | case api.DanmuProtolNormalBrotli: 49 | w := brotli.NewWriter(&b) 50 | w.Write(data) 51 | w.Close() 52 | default: 53 | b.Write(data) 54 | } 55 | } 56 | return b.Bytes() 57 | } 58 | 59 | func parseHeader(data []byte) (header *api.DanmuMessageHeader, err error) { 60 | if len(data) < 16 { 61 | err = headerNotCompleteErr 62 | return 63 | } 64 | b := bytes.NewBuffer(data) 65 | header = new(api.DanmuMessageHeader) 66 | 67 | v := reflect.ValueOf(header).Elem() 68 | for i := 0; i < v.NumField(); i++ { 69 | ptr := v.Field(i).Addr().Interface() 70 | binary.Read(b, binary.BigEndian, ptr) 71 | } 72 | if header.HeaderSize != 16 || 73 | header.ProtoVer > api.DanmuProtolNormalBrotli || 74 | header.OpCode > api.DanmuOpAuthResp { 75 | err = invalidMessageErr 76 | return 77 | } 78 | return 79 | } 80 | 81 | func unpackMessage(room *api.LiveRoom, data []byte) (unpack uint32) { 82 | for dataLen := len(data); dataLen > 0; dataLen = len(data) { 83 | header, _ := parseHeader(data) 84 | if header == nil || (header.Size) > uint32(dataLen) { 85 | return 86 | } 87 | unpack += header.Size 88 | rawMessage := data[header.HeaderSize:header.Size] 89 | data = data[header.Size:] 90 | logging.Debugf("read message, header=%+v", header) 91 | var normalMessage []byte 92 | switch header.ProtoVer { 93 | case api.DanmuProtolNormalZlib: 94 | b := bytes.NewBuffer(rawMessage) 95 | if zr, err := gzip.NewReader(b); err != nil { 96 | logging.Errorf("decompress gzip error, err=%v", err) 97 | continue 98 | } else { 99 | if normalMessage, err = ioutil.ReadAll(zr); err != nil { 100 | logging.Errorf("decompress gzip error, err=%v", err) 101 | continue 102 | } 103 | } 104 | case api.DanmuProtolNormalBrotli: 105 | b := bytes.NewBuffer(rawMessage) 106 | br := brotli.NewReader(b) 107 | var err error 108 | if normalMessage, err = ioutil.ReadAll(br); err != nil { 109 | logging.Errorf("decompress brotli error, err=%v", err) 110 | continue 111 | } 112 | default: 113 | normalMessage = rawMessage 114 | } 115 | logging.Debugf("read message, header=%+v", header) 116 | switch header.OpCode { 117 | case api.DanmuOpHeartBeatResp: 118 | if len(normalMessage) >= 4 { 119 | room.Hot = binary.BigEndian.Uint32(normalMessage) 120 | } 121 | case api.DanmuOpNormal: 122 | if header.ProtoVer == api.DanmuProtolNormal { 123 | danmuMessage := new(api.DanmuMessage) 124 | if err := json.Unmarshal(normalMessage, danmuMessage); err != nil { 125 | logging.Errorf("unmarshal normal message error, err=%v", err) 126 | continue 127 | } 128 | room.MessageChan <- danmuMessage 129 | } else if header.ProtoVer == api.DanmuProtolNormalZlib || header.ProtoVer == api.DanmuProtolNormalBrotli { 130 | for messagesLen := len(normalMessage); messagesLen > 0; messagesLen = len(normalMessage) { 131 | messageHeader, err := parseHeader(normalMessage) 132 | if err != nil { 133 | logging.Errorf("parse message error, err=%v", err) 134 | break 135 | } else if messageHeader.Size > uint32(messagesLen) { 136 | logging.Errorf("header message size overflow") 137 | break 138 | } 139 | oneNormalMessage := normalMessage[messageHeader.HeaderSize:messageHeader.Size] 140 | normalMessage = normalMessage[messageHeader.Size:] 141 | danmuMessage := new(api.DanmuMessage) 142 | if err := json.Unmarshal(oneNormalMessage, danmuMessage); err != nil { 143 | logging.Errorf("unmarshal normal message error, err=%v", err) 144 | continue 145 | } 146 | room.MessageChan <- danmuMessage 147 | } 148 | } 149 | } 150 | } 151 | return 152 | } 153 | 154 | func GetDanmuInfo(client *http.Client, id uint64) (info *api.DanmuInfoResp, err error) { 155 | danmuInfoReq := api.DanmuInfoReq{ID: id} 156 | v, err := query.Values(danmuInfoReq) 157 | if err != nil { 158 | return 159 | } 160 | baseURL := "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo" 161 | realUrl := fmt.Sprintf("%s?%s", baseURL, v.Encode()) 162 | 163 | signedUrl, err := signAndGenerateURL(realUrl) 164 | if err != nil { 165 | return 166 | } 167 | 168 | resp, err := client.Get(signedUrl) 169 | if err != nil { 170 | return 171 | } 172 | defer resp.Body.Close() 173 | body, err := io.ReadAll(resp.Body) 174 | if err != nil { 175 | return 176 | } 177 | danmuInfoResp := new(api.DanmuInfoResp) 178 | err = json.Unmarshal(body, danmuInfoResp) 179 | if err != nil { 180 | return 181 | } 182 | if danmuInfoResp.Code != 0 { 183 | err = errors.New(danmuInfoResp.Message) 184 | } 185 | info = danmuInfoResp 186 | return 187 | } 188 | 189 | func connectDanmuServer(uid uint64, roomID uint64, info *api.DanmuInfoResp) (conn net.Conn, err error) { 190 | for _, HostData := range info.Data.HostList { 191 | timeout := time.Second 192 | conn, err = net.DialTimeout("tcp", net.JoinHostPort(HostData.Host, strconv.Itoa(HostData.Port)), timeout) 193 | if err == nil && conn != nil { 194 | break 195 | } 196 | } 197 | if conn == nil { 198 | return nil, errors.New("no server can connect") 199 | } 200 | danmuAuthPacketReq := api.DanmuAuthPacketReq{ 201 | UID: uid, 202 | RoomID: roomID, 203 | ProtoVer: 3, 204 | Platform: "web", 205 | Type: 2, 206 | Key: info.Data.Token, 207 | } 208 | jsonReq, err := json.Marshal(danmuAuthPacketReq) 209 | if err != nil { 210 | return 211 | } 212 | data := packMessage(jsonReq, api.DanmuProtolHeartBeat, api.DanmuOpAuth, 1) 213 | dataLen := len(data) 214 | n, err := conn.Write(data) 215 | if err != nil { 216 | return 217 | } else if n != dataLen { 218 | err = errors.New("connect server failed") 219 | return 220 | } 221 | resp := make([]byte, 8192) 222 | n, err = conn.Read(resp) 223 | if err != nil { 224 | return 225 | } 226 | danmuHeader, _ := parseHeader(resp) 227 | if danmuHeader == nil { 228 | err = errors.New("parse header failed") 229 | return 230 | } 231 | danmuAuthPacketResp := api.DanmuAuthPacketResp{} 232 | err = json.Unmarshal(resp[danmuHeader.HeaderSize:n], &danmuAuthPacketResp) 233 | if err != nil || danmuAuthPacketResp.Code != 0 { 234 | err = errors.New("connect server auth failed") 235 | return 236 | } 237 | return 238 | } 239 | 240 | func ConnectDanmuServer(uid uint64, roomID uint64, info *api.DanmuInfoResp) (room *api.LiveRoom, err error) { 241 | conn, err := connectDanmuServer(uid, roomID, info) 242 | if err != nil { 243 | return 244 | } 245 | room = &api.LiveRoom{ 246 | UID: uid, 247 | RoomID: roomID, 248 | Hot: 0, 249 | Seq: 1, 250 | MessageChan: make(chan *api.DanmuMessage, 10), 251 | ReqChan: make(chan []byte, 10), 252 | DoneChan: make(chan struct{}), 253 | RetryChan: make(chan struct{}), 254 | StreamConn: conn, 255 | } 256 | 257 | // process write 258 | go processWrite(room) 259 | 260 | // process read 261 | go processRead(room) 262 | 263 | go monitorConn(room) 264 | 265 | return 266 | } 267 | 268 | func heartBeatReq(room *api.LiveRoom) { 269 | body, _ := hex.DecodeString("5b6f626a656374204f626a6563745d") 270 | seq := atomic.AddUint32(&room.Seq, 1) 271 | data := packMessage(body, api.DanmuProtolHeartBeat, api.DanmuOpHeartBeat, seq) 272 | room.ReqChan <- data 273 | } 274 | 275 | func processWrite(room *api.LiveRoom) { 276 | heartBeatReq(room) 277 | heartBeatTicker := time.NewTicker(30 * time.Second) 278 | defer heartBeatTicker.Stop() 279 | dataList := list.New() 280 | doneChan := room.DoneChan 281 | Loop: 282 | for { 283 | select { 284 | case <-doneChan: 285 | break Loop 286 | case <-heartBeatTicker.C: 287 | heartBeatReq(room) 288 | case data := <-room.ReqChan: 289 | for dataList.Len() > 0 { 290 | preData := dataList.Front().Value.([]byte) 291 | // todo Add timeout settings 292 | if _, err := room.StreamConn.Write(preData); err != nil { 293 | if err != nil { 294 | logging.Errorf("connection close from write, err=%v", err) 295 | break Loop 296 | } 297 | } else { 298 | dataList.Remove(dataList.Front()) 299 | } 300 | } 301 | if dataList.Len() == 0 { 302 | _, err := room.StreamConn.Write(data) 303 | if err == nil { 304 | continue 305 | } 306 | } 307 | dataList.PushBack(data) 308 | } 309 | } 310 | logging.Infof("write goroutine quit") 311 | } 312 | 313 | func processRead(room *api.LiveRoom) { 314 | var notComplete []byte 315 | doneChan := room.DoneChan 316 | Loop: 317 | for { 318 | select { 319 | case <-doneChan: 320 | break Loop 321 | default: 322 | data := make([]byte, 64*1024) 323 | // todo Add timeout settings 324 | n, err := room.StreamConn.Read(data) 325 | if err != nil { 326 | close(room.RetryChan) 327 | logging.Errorf("connection close from read, err=%v", err) 328 | break Loop 329 | } 330 | data = data[:n] 331 | if len(notComplete) != 0 { 332 | data = append(notComplete, data...) 333 | } 334 | dataLen := len(data) 335 | if dataLen > 0 { 336 | unpackLen := unpackMessage(room, data) 337 | leftLen := dataLen - int(unpackLen) 338 | if leftLen > 0 { 339 | notComplete = data[unpackLen:] 340 | } else { 341 | notComplete = nil 342 | } 343 | } 344 | } 345 | } 346 | logging.Infof("read goroutine quit") 347 | } 348 | 349 | func monitorConn(room *api.LiveRoom) { 350 | Loop: 351 | for { 352 | select { 353 | case <-room.DoneChan: 354 | break Loop 355 | case <-room.RetryChan: 356 | logging.Infof("retry connect danmu server") 357 | close(room.DoneChan) 358 | client := room.Client 359 | realRoomID := room.RoomID 360 | info, err := GetDanmuInfo(client, realRoomID) 361 | if err != nil { 362 | logging.Fatalf("retry get danmu info failed, err=%v", err) 363 | } 364 | conn, err := connectDanmuServer(room.UID, realRoomID, info) 365 | if err != nil { 366 | logging.Fatalf("retry connect danmu server failed, err=%v", err) 367 | } 368 | logging.Infof("retry connect danmu server success") 369 | room.StreamConn = conn 370 | room.DoneChan = make(chan struct{}) 371 | room.RetryChan = make(chan struct{}) 372 | go processWrite(room) 373 | go processRead(room) 374 | } 375 | } 376 | logging.Infof("monitor goroutine quit") 377 | } 378 | --------------------------------------------------------------------------------