Browse Source

Afreeca

- implementation
Alexey Kim 10 months ago
parent
commit
87ccfdd92f

+ 702 - 0
platform/afreeca.go

@@ -0,0 +1,702 @@
+package platform
+
+import (
+	"errors"
+	"fmt"
+	"git.beejay.kim/WatchDog/ward/internal/tools"
+	"git.beejay.kim/WatchDog/ward/internal/ws"
+	"git.beejay.kim/WatchDog/ward/model"
+	"git.beejay.kim/WatchDog/ward/platform/afreeca"
+	"github.com/kelindar/bitmap"
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/cast"
+	"github.com/tidwall/gjson"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+const (
+	afreecaHeaderReferer     = "https://play.afreecatv.com"
+	afreecaMessageBufferSize = 256
+
+	afreecaStateAuthenticated uint32 = iota
+	afreecaStateQuickView
+)
+
+type Afreeca struct {
+	Username string `yaml:"username"`
+	Password string `yaml:"password"`
+
+	host string
+	rest *http.Client
+	info *afreeca.Channel
+
+	// credentials
+	cookies []*http.Cookie
+	uuid    string
+
+	state           bitmap.Bitmap
+	bridgeConnector ws.ConnectionOptions
+	chatConnector   ws.ConnectionOptions
+}
+
+func (p *Afreeca) Init(bid string) error {
+	var err error
+
+	if p.host = strings.TrimSpace(bid); p.host == "" {
+		return errors.New("broadcaster ID cannot be empty")
+	}
+
+	// Generate UUID
+	p.uuid = afreeca.GenerateUID()
+
+	// Initiate HTTP Client
+	p.rest = &http.Client{
+		Transport: func() *http.Transport {
+			t := http.DefaultTransport.(*http.Transport)
+			t.Proxy = http.ProxyFromEnvironment
+			return t
+		}(),
+		Timeout: time.Second * 30,
+	}
+
+	// Retrieve and validate channel data
+	if p.info, err = p.getChannelData(); err != nil {
+		return err
+	}
+
+	if p.info.Result != 1 {
+		return errors.New("could not fetch channel data")
+	}
+
+	if p.info.PasswordRequired {
+		return errors.New("password is required")
+	}
+
+	p.bridgeConnector = ws.ConnectionOptions{
+		MessageListener: make(chan []byte, afreecaMessageBufferSize),
+		PingPeriod:      time.Second * 20,
+		PingMessage:     afreeca.NewBridgeMessage(afreeca.BridgeCommandKeepAlive).MustMarshall(),
+		Headers: http.Header{
+			"Sec-Websocket-Protocol": []string{"bridge"},
+			"Origin":                 []string{afreecaHeaderReferer},
+		},
+	}
+
+	p.chatConnector = ws.ConnectionOptions{
+		MessageListener: make(chan []byte, afreecaMessageBufferSize),
+		PingPeriod:      time.Minute,
+		PingMessage:     afreeca.NewChatPingMessage(),
+		Headers: http.Header{
+			"Sec-Websocket-Protocol": []string{"chat"},
+			"Origin":                 []string{afreecaHeaderReferer},
+		},
+	}
+
+	return nil
+}
+
+func (p *Afreeca) push(ch chan proto.Message, m proto.Message) error {
+	if ch == nil {
+		return errors.New("could not push message to the closed channel")
+	}
+
+	if m == nil {
+		return errors.New("could not push empty message")
+	}
+
+	select {
+	case ch <- m:
+	}
+
+	return nil
+}
+
+func (p *Afreeca) Connect(ch chan proto.Message) error {
+	if p.info == nil {
+		return errors.New("illegal state: empty channel data")
+	}
+
+	var (
+		bridge *ws.Connection
+		chat   *ws.Connection
+		err    error
+	)
+
+	// Open bridge connection
+	if bridge, err = ws.NewConnection("wss://bridge.afreecatv.com/Websocket", p.bridgeConnector); err != nil {
+		return err
+	}
+
+	if err = p.onBridgeOpened(bridge); err != nil {
+		return err
+	}
+
+	for {
+		select {
+		case buf := <-p.bridgeConnector.MessageListener:
+			m := afreeca.BridgeMessage{}
+			if err = m.UnmarshalJSON(buf); err != nil {
+				log.Error().
+					Str("raw", string(buf)).
+					Err(err).
+					Msg("could not Unmarshall bridge message")
+				return err
+			}
+
+			switch m.Command {
+			case afreeca.BridgeCommandLogin:
+				if err = p.onBridgeAuthenticated(bridge, m.Data); err != nil {
+					return err
+				}
+			case afreeca.BridgeCommandCertTicket:
+				if err = p.onCertTicketReceived(bridge, m.Data); err != nil {
+					return err
+				}
+
+				if chat, err = ws.NewConnection(p.info.ChatServerUrl(), p.chatConnector); err != nil {
+					return err
+				}
+
+				if err = p.onChatOpened(chat); err != nil {
+					return err
+				}
+			case afreeca.BridgeCommandUserCount:
+				t := model.Online{
+					At:           timestamppb.New(time.Now()),
+					Platform:     p.Id(),
+					Broadcaster:  p.host,
+					Raw:          buf,
+					DevicePc:     cast.ToUint64(m.Data["uiJoinChUser"]),
+					DeviceMobile: cast.ToUint64(m.Data["uiMbUser"]),
+				}
+
+				t.Total = t.DeviceMobile + t.DevicePc + t.DeviceUnknown
+				if err = p.push(ch, &t); err != nil {
+					return err
+				}
+			case afreeca.BridgeCommandUserCountExtended:
+				t := model.Online{
+					At:           timestamppb.New(time.Now()),
+					Platform:     p.Id(),
+					Broadcaster:  p.host,
+					Raw:          buf,
+					DevicePc:     cast.ToUint64(m.Data["uiJoinChPCUser"]),
+					DeviceMobile: cast.ToUint64(m.Data["uiJoinChMBUser"]),
+				}
+
+				t.Total = t.DeviceMobile + t.DevicePc + t.DeviceUnknown
+				if err = p.push(ch, &t); err != nil {
+					return err
+				}
+			case afreeca.BridgeCommandClosed:
+				return p.onBridgeClosed(bridge, m.Data)
+			default:
+				log.Debug().
+					Str("command", string(m.Command)).
+					Any("bridge", m.Data).
+					Send()
+			}
+		case buf := <-p.chatConnector.MessageListener:
+			m := afreeca.ChatMessage{}
+			if err = m.Unmarshall(buf); err != nil {
+				log.Error().
+					Err(err).
+					Str("raw", string(buf)).
+					Msg("could not Unmarshall chat message")
+				continue
+			}
+
+			if m.Is(afreeca.Kind.Authorize) {
+				if err = p.onChatAuthenticated(chat); err != nil {
+					return err
+				}
+			} else if m.Is(afreeca.Kind.Authenticate) {
+				if err = chat.Send(afreeca.NewChatRosterRequestMessage()); err != nil {
+					return err
+				}
+			} else if m.Is(afreeca.Kind.Chat) {
+				d := m.Data()
+				if err = p.push(ch, &model.Message{
+					At:          timestamppb.New(time.Now()),
+					Platform:    p.Id(),
+					Broadcaster: p.host,
+					Raw:         buf,
+					User: &model.User{
+						Id:   afreeca.NormalizeUserID(d[1]),
+						Name: d[5],
+					},
+					Text: d[0],
+				}); err != nil {
+					return err
+				}
+			} else if m.Is(afreeca.Kind.ChatEmoticon) {
+				d := m.Data()
+				if err = p.push(ch, &model.Message{
+					At:          timestamppb.New(time.Now()),
+					Platform:    p.Id(),
+					Broadcaster: p.host,
+					Raw:         buf,
+					User: &model.User{
+						Id:   afreeca.NormalizeUserID(d[5]),
+						Name: d[6],
+					},
+					Text: fmt.Sprintf("%s:%d",
+						cast.ToString(d[2]),
+						cast.ToInt(d[3]),
+					),
+					Sticker: true,
+				}); err != nil {
+					return err
+				}
+			} else if m.Is(afreeca.Kind.Donation) {
+				d := m.Data()
+				if err = p.push(ch, &model.Donation{
+					At:          timestamppb.New(time.Now()),
+					Platform:    p.Id(),
+					Broadcaster: p.host,
+					Raw:         buf,
+					User: &model.User{
+						Id:   afreeca.NormalizeUserID(d[1]),
+						Name: d[2],
+					},
+					Amount: cast.ToUint64(d[3]),
+				}); err != nil {
+					return err
+				}
+			} else if m.Is(afreeca.Kind.Roster.List) {
+				d := m.Data()
+				if cast.ToInt(d[0]) != 1 {
+					continue
+				}
+
+				d = d[1:]
+				for i := 0; i < len(d); i++ {
+					id := afreeca.NormalizeUserID(d[i])
+
+					pass := 2
+					if !strings.Contains(d[i+pass], `|`) {
+						pass = 3
+					}
+
+					i += pass
+
+					if err = p.push(ch, &model.RosterChange{
+						At:          timestamppb.New(time.Now()),
+						Platform:    p.Id(),
+						Broadcaster: p.host,
+						Raw:         buf,
+						User: &model.User{
+							Id: id,
+						},
+						Operation: model.RosterChange_OP_JOINED,
+					}); err != nil {
+						return err
+					}
+				}
+			} else if m.Is(afreeca.Kind.Roster.Change) {
+				d := m.Data()
+				op := model.RosterChange_OP_JOINED
+				if cast.ToInt(d[0]) == -1 {
+					op = model.RosterChange_OP_LEFT
+				}
+
+				if err = p.push(ch, &model.RosterChange{
+					At:          timestamppb.New(time.Now()),
+					Platform:    p.Id(),
+					Broadcaster: p.host,
+					Raw:         buf,
+					User: &model.User{
+						Id: afreeca.NormalizeUserID(d[1]),
+					},
+					Operation: op,
+				}); err != nil {
+					return err
+				}
+			} else {
+				log.Debug().
+					Bytes("kind", m.Kind()).
+					Any("chat", m.Data()).
+					Send()
+			}
+		}
+	}
+}
+
+func (p *Afreeca) Id() string {
+	return "afreeca"
+}
+
+func (p *Afreeca) Host() string {
+	return p.host
+}
+
+func (p *Afreeca) makeRequest(meth string, url string, params url.Values) (*http.Request, error) {
+	var (
+		request *http.Request
+		err     error
+	)
+
+	if http.MethodPost == meth {
+		var body io.Reader
+		if params != nil {
+			body = strings.NewReader(params.Encode())
+		}
+
+		request, err = http.NewRequest(meth, url, body)
+	} else {
+		if params != nil {
+			url = fmt.Sprintf("%s?%s", url, params.Encode())
+		}
+
+		request, err = http.NewRequest(meth, url, nil)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	request.Header.Set("Referer", afreecaHeaderReferer)
+
+	if http.MethodPost == meth && params != nil {
+		request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	}
+
+	for i := range p.cookies {
+		request.AddCookie(p.cookies[i])
+	}
+
+	return request, nil
+}
+
+func (p *Afreeca) login() error {
+	if p.Username == "" || p.Password == "" {
+		return errors.New("username or password is empty")
+	}
+
+	var (
+		request  *http.Request
+		response *http.Response
+
+		buf []byte
+		err error
+	)
+
+	if request, err = p.makeRequest(http.MethodPost,
+		"https://login.afreecatv.com/app/LoginAction.php",
+		url.Values{
+			"szWork":        []string{"login"},
+			"szType":        []string{"json"},
+			"szUid":         []string{p.Username},
+			"szPassword":    []string{p.Password},
+			"isSaveId":      []string{"true"},
+			"isSavePw":      []string{"false"},
+			"isSaveJoin":    []string{"false"},
+			"isLoginRetain": []string{"Y"},
+		}); err != nil {
+		return err
+	}
+
+	if response, err = p.rest.Do(request); err != nil {
+		return err
+	}
+
+	//goland:noinspection ALL
+	defer response.Body.Close()
+
+	if buf, err = tools.DecodeHttpResponse(response); err != nil {
+		return err
+	}
+
+	if gjson.GetBytes(buf, "RESULT").Int() != 1 {
+		return errors.New("login failed")
+	}
+
+	p.cookies = response.Cookies()
+	p.state.Set(afreecaStateAuthenticated)
+
+	return nil
+}
+
+func (p *Afreeca) getHLSKey(preset afreeca.Preset) (string, error) {
+	var (
+		request  *http.Request
+		response *http.Response
+
+		buf []byte
+		err error
+	)
+
+	if request, err = p.makeRequest(http.MethodPost,
+		"https://live.afreecatv.com/afreeca/player_live_api.php",
+		url.Values{
+			"bid":         []string{p.host},
+			"bno":         []string{cast.ToString(p.info.Id)},
+			"from_api":    []string{"0"},
+			"mode":        []string{"landing"},
+			"player_type": []string{"html5"},
+			"pwd":         []string{""},
+			"quality":     []string{preset.Name},
+			"stream_type": []string{"common"},
+			"type":        []string{"aid"},
+		}); err != nil {
+
+	}
+
+	if response, err = p.rest.Do(request); err != nil {
+		return "", err
+	}
+
+	//goland:noinspection ALL
+	defer response.Body.Close()
+
+	if buf, err = tools.DecodeHttpResponse(response); err != nil {
+		return "", err
+	}
+
+	if gjson.GetBytes(buf, "CHANNEL.RESULT").Int() != 1 {
+		err = errors.New("could not fetch HLS key")
+		log.Debug().
+			Str("json", string(buf)).
+			Err(err).
+			Send()
+		return "", err
+	}
+
+	if aid := gjson.GetBytes(buf, "CHANNEL.AID").String(); aid == "" {
+		err = errors.New("could not fetch HLS key")
+		log.Debug().
+			Str("json", string(buf)).
+			Err(err).
+			Send()
+		return "", err
+	} else {
+		return aid, nil
+	}
+}
+
+func (p *Afreeca) HLSStream() (*http.Request, error) {
+	var (
+		preset *afreeca.Preset
+		aid    string
+		uri    string
+		params = url.Values{
+			"return_type": []string{"gs_cdn_pc_web"},
+		}
+
+		request  *http.Request
+		response *http.Response
+
+		buf []byte
+		err error
+	)
+
+	if preset = p.info.GetPreset("original"); preset == nil {
+		return nil, errors.New("could not find feasible preset")
+	}
+
+	if aid, err = p.getHLSKey(*preset); err != nil {
+		return nil, err
+	}
+
+	params.Add("broad_key",
+		fmt.Sprintf("%d-common-%s-hls", p.info.Id, preset.Name))
+
+	if request, err = p.makeRequest(http.MethodGet,
+		fmt.Sprintf("%s/broad_stream_assign.html", p.info.RMD),
+		params); err != nil {
+		return nil, err
+	}
+
+	if response, err = p.rest.Do(request); err != nil {
+		return nil, err
+	}
+
+	//goland:noinspection ALL
+	defer response.Body.Close()
+
+	if buf, err = tools.DecodeHttpResponse(response); err != nil {
+		return nil, err
+	}
+
+	if gjson.GetBytes(buf, "result").Int() != 1 {
+		err = errors.New("could not fetch HLS url")
+		log.Debug().
+			Str("json", string(buf)).
+			Err(err).
+			Send()
+		return nil, err
+	}
+
+	if uri = gjson.GetBytes(buf, "view_url").String(); uri == "" {
+		err = errors.New("could not fetch HLS url")
+		log.Debug().
+			Str("json", string(buf)).
+			Err(err).
+			Send()
+		return nil, err
+	}
+
+	return p.makeRequest(http.MethodGet, uri,
+		url.Values{
+			"aid": []string{aid},
+		})
+}
+
+func (p *Afreeca) getChannelData() (*afreeca.Channel, error) {
+	var (
+		request  *http.Request
+		response *http.Response
+
+		ch  afreeca.Channel
+		buf []byte
+		err error
+	)
+
+	if request, err = p.makeRequest(http.MethodPost,
+		fmt.Sprintf("https://live.afreecatv.com/afreeca/player_live_api.php?bjid=%s", p.host),
+		url.Values{
+			"bid":         []string{p.host},
+			"type":        []string{"live"},
+			"pwd":         []string{},
+			"player_type": []string{"html5"},
+			"stream_type": []string{"common"},
+			"quality":     []string{"HD"},
+			"mode":        []string{"landing"},
+			"from_api":    []string{"0"},
+			"is_revive":   []string{"false"},
+		}); err != nil {
+		return nil, err
+	}
+
+	if response, err = p.rest.Do(request); err != nil {
+		return nil, err
+	}
+
+	//goland:noinspection ALL
+	defer response.Body.Close()
+
+	if buf, err = tools.DecodeHttpResponse(response); err != nil {
+		return nil, err
+	}
+
+	if err = ch.Unmarshall(buf); err != nil {
+		return nil, err
+	}
+
+	if !p.state.Contains(afreecaStateAuthenticated) &&
+		(ch.IsLoginRequired() || ch.IsGeoRestricted() || ch.AudienceGrade > 0) {
+		log.Debug().Msg("broadcast is login required / geo restricted / has audience grade restrictions; attempt to login")
+		if err = p.login(); err != nil {
+			return nil, err
+		}
+
+		log.Debug().Msg("successfully logged in")
+		return p.getChannelData()
+	}
+
+	if p.getCookieValue("_au") == "" {
+		if response, err = p.rest.Get(
+			fmt.Sprintf("https://play.afreecatv.com/%s/%d", p.host, ch.Id)); err != nil {
+			return nil, err
+		}
+
+		p.cookies = response.Cookies()
+	}
+
+	return &ch, nil
+}
+
+func (p *Afreeca) getCookieValue(k string) string {
+	for i := range p.cookies {
+		if p.cookies[i].Name == k {
+			return p.cookies[i].Value
+		}
+	}
+
+	return ""
+}
+
+func (p *Afreeca) onBridgeOpened(c *ws.Connection) error {
+	m := afreeca.NewBridgeInitGatewayMessage(p.host, p.uuid, *p.info, p.getCookieValue)
+	return c.Send(m.MustMarshall())
+}
+
+func (p *Afreeca) onBridgeClosed(_ *ws.Connection, data map[string]any) error {
+	var (
+		s   = cast.ToString(data["pcEndingMsg"])
+		err error
+	)
+
+	if s, err = url.QueryUnescape(s); err == nil {
+		log.Debug().Msg(s)
+	}
+
+	return errorClosed
+}
+
+func (p *Afreeca) onBridgeAuthenticated(_ *ws.Connection, data map[string]any) error {
+	// find if we have QuickView
+	if cast.ToUint(data["iMode"]) == 1 {
+		p.state.Set(afreecaStateQuickView)
+	} else {
+		p.state.Remove(afreecaStateQuickView)
+	}
+
+	return nil
+}
+
+func (p *Afreeca) onCertTicketReceived(c *ws.Connection, data map[string]any) error {
+	m := afreeca.NewBridgeInitBroadcastMessage(p.uuid, *p.info, p.getCookieValue)
+	m.AddArgument("append_data", data["pcAppendDat"])
+	m.AddArgument("gw_ticket", data["pcTicket"])
+
+	return c.Send(m.MustMarshall())
+}
+
+func (p *Afreeca) onChatOpened(c *ws.Connection) error {
+	m := afreeca.NewChatAuthorizeMessage(
+		"",
+		p.state.Contains(afreecaStateQuickView),
+	)
+
+	return c.Send(m.Build())
+}
+
+func (p *Afreeca) onChatAuthenticated(c *ws.Connection) error {
+	joinlog := afreeca.BridgeMessageValue{}
+	joinlog.AddTuple("log", func() afreeca.BridgeMessageValueParams {
+		params := afreeca.BridgeMessageValueParams{}
+		params.Add("set_bps", p.info.Bitrate)
+		params.Add("view_bps", 500)
+		params.Add("quality", "sd")
+		params.Add("uuid", p.uuid)
+		params.Add("geo_cc", p.info.GeoName)
+		params.Add("geo_rc", p.info.GeoCode)
+		params.Add("acpt_lang", p.info.AcceptLanguage)
+		params.Add("svc_lang", p.info.ServiceLanguage)
+		params.Add("subscribe", 0)
+		params.Add("lowlatency", 0)
+		params.Add("mode", "landing")
+		return params
+	}())
+	joinlog.AddTuple("pwd", "")
+	joinlog.AddTuple("auth_info", "NULL")
+	joinlog.AddTuple("pver", 2)
+	joinlog.AddTuple("access_system", "html5")
+
+	m := afreeca.NewChatMessage(afreeca.Kind.Authenticate,
+		cast.ToString(p.info.ChatId),
+		p.info.FanToken,
+		"0",
+		"",
+		joinlog.String(),
+		"")
+
+	return c.Send(m.Build())
+}

+ 176 - 0
platform/afreeca/bridge_message.go

@@ -0,0 +1,176 @@
+package afreeca
+
+import (
+	"fmt"
+	"git.beejay.kim/tool/service/ascii"
+	"github.com/mailru/easyjson"
+	"github.com/spf13/cast"
+	"strings"
+)
+
+type BridgeCommand string
+
+const (
+	BridgeCommandInitGateway       BridgeCommand = "INIT_GW"
+	BridgeCommandInitBroadcast     BridgeCommand = "INIT_BROAD"
+	BridgeCommandKeepAlive         BridgeCommand = "KEEPALIVE"
+	BridgeCommandLogin             BridgeCommand = "FLASH_LOGIN"
+	BridgeCommandCertTicket        BridgeCommand = "CERTTICKETEX"
+	BridgeCommandUserCount         BridgeCommand = "GETUSERCNT"
+	BridgeCommandUserCountExtended BridgeCommand = "GETUSERCNTEX"
+	BridgeCommandChannelCommon     BridgeCommand = "JOINCH_COMMON"
+	BridgeCommandChannelInfo       BridgeCommand = "GETCHINFOEX"
+	BridgeCommand_GETBJADCON       BridgeCommand = "GETBJADCON"
+	BridgeCommand_GETITEM_SELL     BridgeCommand = "GETITEM_SELL"
+	BridgeCommandClosed            BridgeCommand = "CLOSECH"
+)
+
+//easyjson:json
+type BridgeMessage struct {
+	Command BridgeCommand  `json:"SVC"`
+	Result  int            `json:"RESULT"`
+	Data    map[string]any `json:"DATA"`
+}
+
+func (bm *BridgeMessage) AddArgument(k string, v any) {
+	switch v.(type) {
+	case int:
+		bm.Data[k] = v.(int)
+	case int8:
+		bm.Data[k] = v.(int8)
+	case int16:
+		bm.Data[k] = v.(int16)
+	case int32:
+		bm.Data[k] = v.(int32)
+	case int64:
+		bm.Data[k] = v.(int64)
+	case uint:
+		bm.Data[k] = v.(uint)
+	case uint8:
+		bm.Data[k] = v.(uint8)
+	case uint16:
+		bm.Data[k] = v.(uint16)
+	case uint32:
+		bm.Data[k] = v.(uint32)
+	case uint64:
+		bm.Data[k] = v.(uint64)
+	default:
+		bm.Data[k] = cast.ToString(v)
+	}
+}
+
+func (bm *BridgeMessage) MustMarshall() []byte {
+	buf, err := easyjson.Marshal(bm)
+	if err != nil {
+		return nil
+	}
+
+	return buf
+}
+
+type BridgeMessageValue []string
+
+func (arg *BridgeMessageValue) AddTuple(k string, v any) {
+	var list []string
+	if list = *arg; list == nil {
+		list = []string{}
+	}
+
+	*arg = append(list, fmt.Sprintf(`%s%s`, k, cast.ToString(v)))
+}
+
+func (arg BridgeMessageValue) String() string {
+	return strings.Join(arg, "")
+}
+
+type BridgeMessageValueParams []string
+
+func (arg BridgeMessageValueParams) String() string {
+	return strings.Join(arg, "")
+}
+
+func (arg *BridgeMessageValueParams) Add(k string, v any) {
+	var list []string
+	if list = *arg; list == nil {
+		list = []string{}
+	}
+
+	ack := string([]byte{ascii.Acknowledgement})
+	*arg = append(list, ack, "&", ack, k, ack, "=", ack, cast.ToString(v))
+}
+
+func NewBridgeInitGatewayMessage(bid string, uuid string, ch Channel, cookieGetter func(string) string) *BridgeMessage {
+	m := NewBridgeMessage(BridgeCommandInitGateway)
+	m.AddArgument("BJID", bid)
+	m.AddArgument("JOINLOG", makeJoinLog(ch, cookieGetter))
+	m.AddArgument("QUALITY", "sd")
+	m.AddArgument("addinfo", func() BridgeMessageValue {
+		info := BridgeMessageValue{}
+		info.AddTuple("ad_lang", "ko")
+		info.AddTuple("is_auto", 0)
+		return info
+	}())
+	m.AddArgument("broadno", ch.Id)
+	m.AddArgument("category", ch.Category)
+	m.AddArgument("cc_cli_type", 19)
+	m.AddArgument("cli_type", 44)
+	m.AddArgument("cookie", "")
+	m.AddArgument("fanticket", ch.FanToken)
+	m.AddArgument("gate_ip", ch.GatewayIp)
+	m.AddArgument("gate_port", ch.GatewayPort)
+	m.AddArgument("guid", uuid)
+	m.AddArgument("update_info", 0)
+	return m
+}
+
+func NewBridgeInitBroadcastMessage(uuid string, ch Channel, cookieGetter func(string) string) *BridgeMessage {
+	m := NewBridgeMessage(BridgeCommandInitBroadcast)
+	m.AddArgument("JOINLOG", makeJoinLog(ch, cookieGetter))
+	m.AddArgument("QUALITY", "sd")
+	m.AddArgument("cc_cli_type", 19)
+	m.AddArgument("cli_type", 44)
+	m.AddArgument("center_ip", ch.CenterIp)
+	m.AddArgument("center_port", ch.CenterPort)
+	m.AddArgument("guid", uuid)
+	m.AddArgument("passwd", "")
+	m.AddArgument("append_data", "")
+	m.AddArgument("gw_ticket", "")
+	return m
+}
+
+func NewBridgeMessage(cmd BridgeCommand) *BridgeMessage {
+	return &BridgeMessage{
+		Command: cmd,
+		Data:    map[string]any{},
+	}
+}
+
+func makeJoinLog(ch Channel, cookieGetter func(string) string) BridgeMessageValue {
+	joinlog := BridgeMessageValue{}
+	joinlog.AddTuple("log", func() BridgeMessageValueParams {
+		params := BridgeMessageValueParams{}
+		params.Add("uuid", cookieGetter("_au"))
+		params.Add("geo_cc", ch.GeoName)
+		params.Add("geo_rc", ch.GeoCode)
+		params.Add("acpt_lang", ch.AcceptLanguage)
+		params.Add("svc_lang", ch.ServiceLanguage)
+		params.Add("os", "mac")
+		params.Add("is_streamer", false)
+		params.Add("is_rejoin", false)
+		params.Add("is_streamer", false)
+		params.Add("is_auto", false)
+		params.Add("is_support_adaptive", false)
+		params.Add("uuid_3rd", cookieGetter("_au3rd"))
+		params.Add("subscribe", -1)
+		return params
+	}())
+
+	joinlog.AddTuple("liveualog", func() BridgeMessageValueParams {
+		params := BridgeMessageValueParams{}
+		params.Add("is_clearmode", false)
+		params.Add("lowlatency", 0)
+		return params
+	}())
+
+	return joinlog
+}

+ 141 - 0
platform/afreeca/bridge_message_easyjson.go

@@ -0,0 +1,141 @@
+// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
+
+package afreeca
+
+import (
+	json "encoding/json"
+	easyjson "github.com/mailru/easyjson"
+	jlexer "github.com/mailru/easyjson/jlexer"
+	jwriter "github.com/mailru/easyjson/jwriter"
+)
+
+// suppress unused package warning
+var (
+	_ *json.RawMessage
+	_ *jlexer.Lexer
+	_ *jwriter.Writer
+	_ easyjson.Marshaler
+)
+
+func easyjsonAc9440cbDecodeGitBeejayKimWatchDogWardPlatformAfreeca(in *jlexer.Lexer, out *BridgeMessage) {
+	isTopLevel := in.IsStart()
+	if in.IsNull() {
+		if isTopLevel {
+			in.Consumed()
+		}
+		in.Skip()
+		return
+	}
+	in.Delim('{')
+	for !in.IsDelim('}') {
+		key := in.UnsafeFieldName(false)
+		in.WantColon()
+		if in.IsNull() {
+			in.Skip()
+			in.WantComma()
+			continue
+		}
+		switch key {
+		case "SVC":
+			out.Command = BridgeCommand(in.String())
+		case "RESULT":
+			out.Result = int(in.Int())
+		case "DATA":
+			if in.IsNull() {
+				in.Skip()
+			} else {
+				in.Delim('{')
+				out.Data = make(map[string]interface{})
+				for !in.IsDelim('}') {
+					key := string(in.String())
+					in.WantColon()
+					var v1 interface{}
+					if m, ok := v1.(easyjson.Unmarshaler); ok {
+						m.UnmarshalEasyJSON(in)
+					} else if m, ok := v1.(json.Unmarshaler); ok {
+						_ = m.UnmarshalJSON(in.Raw())
+					} else {
+						v1 = in.Interface()
+					}
+					(out.Data)[key] = v1
+					in.WantComma()
+				}
+				in.Delim('}')
+			}
+		default:
+			in.SkipRecursive()
+		}
+		in.WantComma()
+	}
+	in.Delim('}')
+	if isTopLevel {
+		in.Consumed()
+	}
+}
+func easyjsonAc9440cbEncodeGitBeejayKimWatchDogWardPlatformAfreeca(out *jwriter.Writer, in BridgeMessage) {
+	out.RawByte('{')
+	first := true
+	_ = first
+	{
+		const prefix string = ",\"SVC\":"
+		out.RawString(prefix[1:])
+		out.String(string(in.Command))
+	}
+	{
+		const prefix string = ",\"RESULT\":"
+		out.RawString(prefix)
+		out.Int(int(in.Result))
+	}
+	{
+		const prefix string = ",\"DATA\":"
+		out.RawString(prefix)
+		if in.Data == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
+			out.RawString(`null`)
+		} else {
+			out.RawByte('{')
+			v2First := true
+			for v2Name, v2Value := range in.Data {
+				if v2First {
+					v2First = false
+				} else {
+					out.RawByte(',')
+				}
+				out.String(string(v2Name))
+				out.RawByte(':')
+				if m, ok := v2Value.(easyjson.Marshaler); ok {
+					m.MarshalEasyJSON(out)
+				} else if m, ok := v2Value.(json.Marshaler); ok {
+					out.Raw(m.MarshalJSON())
+				} else {
+					out.Raw(json.Marshal(v2Value))
+				}
+			}
+			out.RawByte('}')
+		}
+	}
+	out.RawByte('}')
+}
+
+// MarshalJSON supports json.Marshaler interface
+func (v BridgeMessage) MarshalJSON() ([]byte, error) {
+	w := jwriter.Writer{}
+	easyjsonAc9440cbEncodeGitBeejayKimWatchDogWardPlatformAfreeca(&w, v)
+	return w.Buffer.BuildBytes(), w.Error
+}
+
+// MarshalEasyJSON supports easyjson.Marshaler interface
+func (v BridgeMessage) MarshalEasyJSON(w *jwriter.Writer) {
+	easyjsonAc9440cbEncodeGitBeejayKimWatchDogWardPlatformAfreeca(w, v)
+}
+
+// UnmarshalJSON supports json.Unmarshaler interface
+func (v *BridgeMessage) UnmarshalJSON(data []byte) error {
+	r := jlexer.Lexer{Data: data}
+	easyjsonAc9440cbDecodeGitBeejayKimWatchDogWardPlatformAfreeca(&r, v)
+	return r.Error()
+}
+
+// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
+func (v *BridgeMessage) UnmarshalEasyJSON(l *jlexer.Lexer) {
+	easyjsonAc9440cbDecodeGitBeejayKimWatchDogWardPlatformAfreeca(l, v)
+}

+ 134 - 0
platform/afreeca/channel.go

@@ -0,0 +1,134 @@
+package afreeca
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/samber/lo"
+	"github.com/tidwall/gjson"
+	"strings"
+)
+
+type Preset struct {
+	Label      string `json:"label"`
+	Resolution string `json:"label_resolution"`
+	Name       string `json:"name"`
+	Bitrate    uint64 `json:"bps"`
+}
+
+type Channel struct {
+	Result int64
+
+	GeoName string
+	GeoCode uint64
+
+	AcceptLanguage  string
+	ServiceLanguage string
+
+	Id               uint64
+	Title            string
+	Category         string
+	BroadcasterId    string
+	BroadcasterName  string
+	PasswordRequired bool
+	AudienceGrade    uint64
+
+	RMD        string
+	Resolution string
+	Bitrate    int64
+	Presets    []Preset
+
+	Token    string
+	FanToken string
+
+	GatewayIp   string
+	GatewayPort uint64
+
+	CenterIp   string
+	CenterPort uint64
+
+	ChatDomain string
+	ChatIp     string
+	ChatPort   uint64
+	ChatId     uint64
+}
+
+func (c *Channel) Unmarshall(b []byte) error {
+	c.Result = gjson.GetBytes(b, "CHANNEL.RESULT").Int()
+
+	c.GeoName = gjson.GetBytes(b, "CHANNEL.geo_cc").String()
+	c.GeoCode = gjson.GetBytes(b, "CHANNEL.geo_rc").Uint()
+
+	c.AcceptLanguage = gjson.GetBytes(b, "CHANNEL.acpt_lang").String()
+	c.ServiceLanguage = gjson.GetBytes(b, "CHANNEL.svc_lang").String()
+
+	c.Id = gjson.GetBytes(b, "CHANNEL.BNO").Uint()
+	c.Title = gjson.GetBytes(b, "CHANNEL.TITLE").String()
+	c.Category = gjson.GetBytes(b, "CHANNEL.CATE").String()
+	c.BroadcasterId = gjson.GetBytes(b, "CHANNEL.BJID").String()
+	c.BroadcasterName = gjson.GetBytes(b, "CHANNEL.BJNICK").String()
+	c.PasswordRequired = gjson.GetBytes(b, "CHANNEL.BPWD").String() == "Y"
+	c.AudienceGrade = gjson.GetBytes(b, "CHANNEL.GRADE").Uint()
+
+	c.RMD = gjson.GetBytes(b, "CHANNEL.RMD").String()
+	c.Resolution = gjson.GetBytes(b, "CHANNEL.RESOLUTION").String()
+	c.Bitrate = gjson.GetBytes(b, "CHANNEL.BPS").Int()
+
+	if gjson.GetBytes(b, "CHANNEL.VIEWPRESET").Exists() {
+		if err := json.Unmarshal([]byte(gjson.GetBytes(b, "CHANNEL.VIEWPRESET").Raw), &c.Presets); err != nil {
+			return err
+		}
+	}
+
+	c.Token = gjson.GetBytes(b, "CHANNEL.TK").String()
+	c.FanToken = gjson.GetBytes(b, "CHANNEL.FTK").String()
+
+	c.GatewayIp = gjson.GetBytes(b, "CHANNEL.GWIP").String()
+	c.GatewayPort = gjson.GetBytes(b, "CHANNEL.GWPT").Uint()
+
+	c.CenterIp = gjson.GetBytes(b, "CHANNEL.CTIP").String()
+	c.CenterPort = gjson.GetBytes(b, "CHANNEL.CTPT").Uint()
+
+	c.ChatDomain = gjson.GetBytes(b, "CHANNEL.CHDOMAIN").String()
+	c.ChatIp = gjson.GetBytes(b, "CHANNEL.CHIP").String()
+	c.ChatPort = gjson.GetBytes(b, "CHANNEL.CHPT").Uint()
+	c.ChatId = gjson.GetBytes(b, "CHANNEL.CHATNO").Uint()
+
+	return nil
+}
+
+func (c Channel) IsLoginRequired() bool {
+	return c.Result == -6
+}
+
+func (c Channel) IsGeoRestricted() bool {
+	return c.Result == -2
+}
+
+func (c Channel) ChatServerUrl() string {
+	var (
+		h = c.ChatIp
+		p = c.ChatPort
+	)
+
+	if c.ChatDomain != "" {
+		h = c.ChatDomain
+	}
+
+	return fmt.Sprintf("ws://%s:%d/Websocket/%s", h, p, c.BroadcasterId)
+}
+
+func (c Channel) GetPreset(s string) *Preset {
+	if s = strings.TrimSpace(s); s == "" {
+		s = "auto"
+	}
+
+	presets := lo.Filter(c.Presets, func(item Preset, _ int) bool {
+		return item.Name == s
+	})
+
+	if presets == nil {
+		return nil
+	}
+
+	return &presets[0]
+}

+ 171 - 0
platform/afreeca/chat_message.go

@@ -0,0 +1,171 @@
+package afreeca
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"git.beejay.kim/tool/service/ascii"
+	"github.com/samber/lo"
+	"strconv"
+	"time"
+)
+
+type kind []byte
+
+func newKind(category, index int) kind {
+	return []byte(
+		fmt.Sprintf("%04d%03d", category, index))
+}
+
+type kindList struct {
+	Ping          kind
+	Authorize     kind
+	Authenticate  kind
+	Authenticated kind
+	Roster        struct {
+		List   kind
+		Change kind
+		Status kind
+	}
+
+	Chat         kind
+	ChatEmoticon kind
+
+	Donation kind
+}
+
+var (
+	Kind = &kindList{
+		Ping:          newKind(0, 0),
+		Authorize:     newKind(1, 0),
+		Authenticate:  newKind(2, 0),
+		Authenticated: newKind(94, 0),
+
+		Chat:         newKind(5, 0),
+		ChatEmoticon: newKind(109, 0),
+
+		Donation: newKind(18, 0),
+
+		Roster: struct {
+			List   kind
+			Change kind
+			Status kind
+		}{
+			List:   newKind(4, 1),
+			Change: newKind(4, 0),
+			Status: newKind(13, 0),
+		},
+	}
+)
+
+type ChatMessage struct {
+	raw []byte
+
+	kind kind
+	data []string
+	at   time.Time
+}
+
+func (m *ChatMessage) Unmarshall(buf []byte) error {
+	m.at = time.Now()
+	m.raw = buf
+
+	if buf == nil || len(buf) < 14 {
+		return errors.New("empty data")
+	}
+
+	if buf[0] != ascii.Escape ||
+		buf[1] != ascii.HorizontalTab ||
+		buf[14] != ascii.FormFeed {
+		return errors.New("illegal data format")
+	}
+
+	m.data = []string{}
+	m.kind = buf[2:9]
+
+	var (
+		size = 0
+		data bytes.Buffer
+		err  error
+	)
+
+	if size, err = strconv.Atoi(string(buf[9:12])); err != nil {
+		return errors.New("illegal data size")
+	}
+
+	for i := range buf[15:] {
+		last := i == size-2
+		b := buf[15+i : 16+i]
+
+		if ascii.FormFeed == b[0] {
+			m.data = append(m.data, data.String())
+			data.Reset()
+			continue
+		}
+
+		data.Write(b)
+		if last {
+			m.data = append(m.data, data.String())
+		}
+	}
+
+	return nil
+}
+
+func (m *ChatMessage) Build() []byte {
+	var (
+		buf  bytes.Buffer
+		size = lo.SumBy(m.data, func(item string) int {
+			return len(item)
+		})
+	)
+
+	buf.WriteByte(ascii.Escape)
+	buf.WriteByte(ascii.HorizontalTab)
+	buf.Write(m.kind)
+	buf.WriteString(fmt.Sprintf("%03d00", size+len(m.data)))
+
+	for i := range m.data {
+		buf.WriteByte(ascii.FormFeed)
+		buf.WriteString(m.data[i])
+	}
+
+	return buf.Bytes()
+}
+
+func (m ChatMessage) Is(k kind) bool {
+	return bytes.Compare(m.kind, k) == 0
+}
+
+func (m ChatMessage) Kind() []byte {
+	return m.kind
+}
+
+func (m ChatMessage) Data() []string {
+	return m.data
+}
+
+func NewChatMessage(k kind, args ...string) *ChatMessage {
+	return &ChatMessage{
+		kind: k,
+		data: args,
+		at:   time.Now(),
+	}
+}
+
+func NewChatAuthorizeMessage(token string, withQuickView bool) *ChatMessage {
+	flag := "16"
+	if withQuickView {
+		flag = "524304"
+	}
+
+	return NewChatMessage(Kind.Authorize, token, "", flag, "")
+}
+
+func NewChatPingMessage() []byte {
+	return NewChatMessage(Kind.Ping, "").Build()
+}
+
+func NewChatRosterRequestMessage() []byte {
+	return NewChatMessage(Kind.Roster.Change, "").Build()
+}

+ 64 - 0
platform/afreeca/utils.go

@@ -0,0 +1,64 @@
+package afreeca
+
+import (
+	"fmt"
+	"math"
+	"math/rand"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+const regexUserId = `^(?is)(\w+)(?:\(([0-9]+)\))?$`
+
+func GenerateUID() string {
+	mask := []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"}
+	arr := make([]string, 36)
+	k := 0
+
+	rand.New(rand.NewSource(time.Now().UnixNano()))
+
+	for i := 0; i < 8; i++ {
+		k++
+		arr[k] = mask[int64(math.Floor(16*rand.Float64()))]
+	}
+
+	for i := 0; i < 3; i++ {
+		for k := 0; k < 4; k++ {
+			k++
+			arr[k] = mask[int64(math.Floor(16*rand.Float64()))]
+		}
+	}
+
+	hex := fmt.Sprintf("0000000%s",
+		strings.ToUpper(strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 16)))
+	l := len(hex)
+	hexMask := []byte(hex[l-8 : l])
+
+	for i := 0; i < 8; i++ {
+		k++
+		arr[k] = string(hexMask[i])
+	}
+
+	for i := 0; i < 4; i++ {
+		k++
+		arr[k] = mask[int64(math.Floor(16*rand.Float64()))]
+	}
+
+	return strings.Join(arr, "")
+}
+
+func NormalizeUserID(s string) string {
+	var (
+		m      = regexp.MustCompile(regexUserId).FindStringSubmatch(s)
+		groups int
+		userId = strings.TrimSpace(s)
+	)
+
+	if groups = len(m); groups == 0 {
+		return userId
+	}
+
+	return strings.TrimSpace(m[1])
+}