192 lines
7.2 KiB
Go
192 lines
7.2 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
|
||
"github.com/gotd/td/tg"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// RTMP 直播(Live Stream)RPC:createGroupCall(rtmp_stream) 建房后,推流方(OBS)
|
||
// 用 getGroupCallStreamRtmpUrl 拿到的 url/key 推流,观众 join 后经
|
||
// upload.getFile(inputGroupCallStream) 拉 broadcast chunk。
|
||
//
|
||
// 与普通语音聊天(RTC/SFU)关键差异:RTMP join 不建 SFU 连接、不做 ssrc 唯一性
|
||
// 媒体面校验;updateGroupCallConnection.params 返回 {"stream":true,"rtmp":true}
|
||
// 让 TDesktop 切 broadcast 模式(ParseJoinResponse → JoinBroadcastStream)。
|
||
|
||
// rtmpConnectionParams 是 RTMP 观众 join 响应的下行 JSON(tgcalls broadcast 分支)。
|
||
// 可管理者附 rtmp_stream_url/key 供直播设置页展示(普通观众不下发,避免泄漏推流凭据)。
|
||
type rtmpConnectionParams struct {
|
||
Stream bool `json:"stream"`
|
||
Rtmp bool `json:"rtmp"`
|
||
RtmpStreamURL string `json:"rtmp_stream_url,omitempty"`
|
||
RtmpStreamKey string `json:"rtmp_stream_key,omitempty"`
|
||
}
|
||
|
||
func buildRtmpConnectionParams(url, key string) (string, error) {
|
||
p := rtmpConnectionParams{Stream: true, Rtmp: true, RtmpStreamURL: url, RtmpStreamKey: key}
|
||
out, err := json.Marshal(p)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(out), nil
|
||
}
|
||
|
||
// joinRtmpGroupCall 处理 RTMP 直播房间的 joinGroupCall。
|
||
func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, req *tg.PhoneJoinGroupCallRequest) (tg.UpdatesClass, error) {
|
||
// 房间上限:RTMP 观众也计入 participants_count;rejoin(已在会换 ssrc)不受限。
|
||
if max := r.cfg.GroupCallMaxParticipants; max > 0 && scope.call.ParticipantsCount >= max {
|
||
if p, found, _ := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID); !found || p.Left {
|
||
return nil, groupCallForbiddenErr()
|
||
}
|
||
}
|
||
joinAsChannelID, err := r.groupCallJoinAsChannelID(scope, req.JoinAs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
// RTMP 观众的 join JSON 仍带 tgcalls ssrc(客户端为拉流也建了本地 controller);
|
||
// 解析失败/缺失不致命——直播不需要 SFU,用随机 ssrc 记账保证参与者行有效。
|
||
ssrc := int64(0)
|
||
if _, s, err := parseGroupCallJoinPayload(req.Params.Data); err == nil {
|
||
ssrc = s
|
||
}
|
||
if ssrc == 0 {
|
||
ssrc = randomSSRC()
|
||
}
|
||
// RTMP 观众(含创建者,其经 OBS 独立推流,在 group call 里同样是纯观众)一律
|
||
// force-muted:IsAdmin=false 让 store 对 join_muted 房间置 muted+muted_by_admin,
|
||
// self 行输出 muted=true / can_self_unmute=false,TDesktop 才会稳定停在 stream 模式。
|
||
mut, err := r.deps.GroupCalls.Join(ctx, domain.JoinGroupCallRequest{
|
||
CallID: scope.call.ID,
|
||
UserID: scope.userID,
|
||
JoinAsChannelID: joinAsChannelID,
|
||
SSRC: ssrc,
|
||
Muted: true,
|
||
IsAdmin: false,
|
||
Now: now,
|
||
})
|
||
if err != nil {
|
||
return nil, groupCallErr(err)
|
||
}
|
||
// 可管理者拿推流 url/key(直播设置页展示);普通观众只得 stream:true。
|
||
var url, key string
|
||
if scope.canManage() && r.deps.GroupCalls != nil {
|
||
if k, kerr := r.deps.GroupCalls.RtmpStreamKey(ctx, scope.channel.ID, false, now); kerr == nil {
|
||
key = k
|
||
url = r.rtmpIngestURL()
|
||
}
|
||
}
|
||
params, err := buildRtmpConnectionParams(url, key)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||
&tg.UpdateGroupCallParticipants{
|
||
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
|
||
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
|
||
Version: mut.Call.Version,
|
||
}, []int64{scope.userID})
|
||
// updateGroupCall 必须先于 updateGroupCallConnection:TDesktop 按序 applyUpdates,
|
||
// 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打
|
||
// "Api Error: Empty stream_dc_id" 并 fallback 主 DC。
|
||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
||
if channel.ID != 0 {
|
||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||
}
|
||
out.Updates = append(out.Updates, callUpdate)
|
||
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
||
return out, nil
|
||
}
|
||
|
||
// onPhoneGetGroupCallStreamRtmpURL 返回频道的 RTMP 推流 url/key(创建前预览、
|
||
// 直播设置页展示、revoke 轮换)。仅频道管理员可调;revoke=true 生成新 key,旧 key
|
||
// 立即失效并断开正在进行的推流。
|
||
func (r *Router) onPhoneGetGroupCallStreamRtmpURL(ctx context.Context, req *tg.PhoneGetGroupCallStreamRtmpURLRequest) (*tg.PhoneGroupCallStreamRtmpURL, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if r.deps.GroupCalls == nil || r.deps.Channels == nil {
|
||
return nil, notImplementedErr()
|
||
}
|
||
userID, err := r.phoneRequireUser(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID)
|
||
if err != nil {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) {
|
||
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
key, err := r.deps.GroupCalls.RtmpStreamKey(ctx, peer.ID, req.Revoke, now)
|
||
if err != nil {
|
||
return nil, groupCallErr(err)
|
||
}
|
||
if req.Revoke && r.deps.LiveStreams != nil {
|
||
// 旧 key 失效后仍在推的连接必须断开(否则用旧 key 的推流会继续被接收)。
|
||
r.deps.LiveStreams.DropChannel(peer.ID)
|
||
}
|
||
return &tg.PhoneGroupCallStreamRtmpURL{URL: r.rtmpIngestURL(), Key: key}, nil
|
||
}
|
||
|
||
// onPhoneGetGroupCallStreamChannels 返回 RTMP 直播的当前时间轴(unified:channel=1
|
||
// scale=0),供 tgcalls 决定从哪个 time_ms 起拉 chunk。无活跃推流时返回空列表,
|
||
// TDesktop 据此显示"等待推流"占位并循环重试。
|
||
func (r *Router) onPhoneGetGroupCallStreamChannels(ctx context.Context, call tg.InputGroupCallClass) (*tg.PhoneGroupCallStreamChannels, error) {
|
||
scope, err := r.groupCallScopeFrom(ctx, call)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := &tg.PhoneGroupCallStreamChannels{Channels: []tg.GroupCallStreamChannel{}}
|
||
if !scope.call.RtmpStream || r.deps.LiveStreams == nil || scope.channel.ID == 0 {
|
||
return out, nil
|
||
}
|
||
for _, ch := range r.deps.LiveStreams.StreamChannels(scope.channel.ID) {
|
||
out.Channels = append(out.Channels, tg.GroupCallStreamChannel{
|
||
Channel: ch.Channel,
|
||
Scale: ch.Scale,
|
||
LastTimestampMs: ch.LastTimestampMs,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// rtmpIngestURL 返回展示给推流端的 RTMP 服务器地址。
|
||
func (r *Router) rtmpIngestURL() string {
|
||
if r.cfg.RtmpIngestURL != "" {
|
||
return r.cfg.RtmpIngestURL
|
||
}
|
||
host := r.cfg.IP
|
||
if host == "" {
|
||
host = "127.0.0.1"
|
||
}
|
||
return "rtmp://" + host + ":2400/live"
|
||
}
|
||
|
||
func randomSSRC() int64 {
|
||
var buf [4]byte
|
||
if _, err := rand.Read(buf[:]); err != nil {
|
||
return 1
|
||
}
|
||
v := int64(binary.BigEndian.Uint32(buf[:]))
|
||
if v == 0 {
|
||
v = 1
|
||
}
|
||
return v
|
||
}
|