feat: sync group call livestream support

This commit is contained in:
A 2026-07-06 14:31:19 +08:00
parent 56d995474c
commit f1a27996d3
37 changed files with 2219 additions and 83 deletions

View file

@ -0,0 +1,192 @@
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
}