package videochat import ( "context" "encoding/json" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/sqldb" "encore.dev/types/uuid" "github.com/livekit/protocol/livekit" ) var profilesDB = sqldb.Named("profiles") type CreateRoomParams struct { Name string `json:"name"` MaxParticipants uint32 `json:"max_participants"` EmptyTimeout uint32 `json:"empty_timeout"` } type Room struct { Active bool `json:"active"` OwnerID string `json:"owner_id"` DisplayName string `json:"display_name"` Mode string `json:"mode"` SID string `json:"sid"` Name string `json:"name"` NumParticipants uint32 `json:"num_participants"` MaxParticipants uint32 `json:"max_participants"` CreationTime int64 `json:"creation_time"` AvatarURL string `json:"avatar_url,omitempty"` } type ListRoomsResponse struct { Rooms []*Room `json:"rooms"` } func roomResponse(room *livekit.Room) *Room { metadata, _ := readBroadcast(room) return &Room{Active: true, OwnerID: metadata.OwnerID, DisplayName: metadata.DisplayName, Mode: metadata.Mode, SID: room.Sid, Name: room.Name, NumParticipants: room.NumParticipants, MaxParticipants: room.MaxParticipants, CreationTime: room.CreationTime} } // CreateRoom creates a LiveKit room. Requires an administrator. // //encore:api auth method=POST path=/videochat/rooms func CreateRoom(ctx context.Context, p *CreateRoomParams) (*Room, error) { if err := requireAdmin(); err != nil { return nil, err } if err := validateRoomName(p.Name); err != nil { return nil, err } client, _, err := liveKitClient() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, apiTimeout) defer cancel() uid, _ := auth.UserID() id, err := uuid.NewV4() if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to create room identifier") } metadata, err := json.Marshal(broadcastMetadata{Mode: "broadcast", OwnerID: string(uid), DisplayName: p.Name}) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to encode room metadata") } room, err := client.CreateRoom(ctx, &livekit.CreateRoomRequest{ Name: "broadcast-" + id.String(), Metadata: string(metadata), MaxParticipants: p.MaxParticipants, EmptyTimeout: p.EmptyTimeout, }) if err != nil { return nil, liveKitError(err) } _ = logBroadcastSession(ctx, string(uid), room.Sid, room.Name, "OPEN") return roomResponse(room), nil } // ListRooms lists live broadcasts and enabled personal setups for authenticated viewers. // Configured rooms absent from LiveKit are returned with active=false. // //encore:api auth method=GET path=/videochat/rooms func ListRooms(ctx context.Context) (*ListRoomsResponse, error) { if _, ok := auth.UserID(); !ok { return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} } client, _, err := liveKitClient() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, apiTimeout) defer cancel() result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{}) if err != nil { return nil, liveKitError(err) } response := &ListRoomsResponse{Rooms: make([]*Room, 0, len(result.Rooms))} activeSIDs := make(map[string]bool, len(result.Rooms)) for _, room := range result.Rooms { if metadata, ok := readBroadcast(room); ok { activeSIDs[room.Sid] = true _ = logBroadcastSession(ctx, metadata.OwnerID, room.Sid, room.Name, "OPEN") response.Rooms = append(response.Rooms, roomResponse(room)) } } syncClosedSessions(ctx, activeSIDs) configured, err := configuredBroadcastRooms(ctx) if err != nil { return nil, err } response.Rooms = mergeBroadcastRooms(response.Rooms, configured) return response, nil } func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) { rows, err := profilesDB.Query(ctx, ` SELECT p.user_id::text, p.display_name, p.avatar_url, COALESCE(s.room_name, '') as room_name, COALESCE(s.can_broadcast, FALSE) as can_broadcast, p.member_type FROM user_profiles p LEFT JOIN personal_setup s ON s.user_id = p.user_id WHERE (s.can_broadcast = TRUE AND s.room_name <> '') OR (p.member_type = 'broadcaster') ORDER BY p.display_name, p.user_id `) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms") } defer rows.Close() rooms := make([]*Room, 0) usedNames := make(map[string]bool) for rows.Next() { var ( userID string displayName string avatarURL string roomName string canBroadcast bool memberType string ) if err := rows.Scan(&userID, &displayName, &avatarURL, &roomName, &canBroadcast, &memberType); err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to read configured broadcast room") } finalRoomName := roomName finalDisplayName := roomName if finalRoomName == "" { if validateRoomName(displayName) == nil && !usedNames[displayName] { finalRoomName = displayName } else if len(userID) >= 8 { finalRoomName = "broadcaster-" + userID[:8] } else { finalRoomName = "broadcaster-" + userID } finalDisplayName = displayName } if finalDisplayName == "" { finalDisplayName = finalRoomName } usedNames[finalRoomName] = true room := &Room{ Mode: "broadcast", OwnerID: userID, Name: finalRoomName, DisplayName: finalDisplayName, AvatarURL: avatarURL, } rooms = append(rooms, room) } if err := rows.Err(); err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms") } return rooms, nil } func mergeBroadcastRooms(active, configured []*Room) []*Room { type ownerTitle struct{ owner, title string } names := make(map[string]bool, len(active)) titles := make(map[ownerTitle]bool, len(active)) avatarMap := make(map[string]string) for _, room := range configured { if room.OwnerID != "" && room.AvatarURL != "" { avatarMap[room.OwnerID] = room.AvatarURL } } for _, room := range active { names[room.Name] = true titles[ownerTitle{room.OwnerID, room.DisplayName}] = true titles[ownerTitle{room.OwnerID, room.Name}] = true if room.AvatarURL == "" && room.OwnerID != "" { room.AvatarURL = avatarMap[room.OwnerID] } } for _, room := range configured { if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] && !titles[ownerTitle{room.OwnerID, room.DisplayName}] { active = append(active, room) } } return active } // DeleteRoom closes a room and disconnects its participants. Requires the room owner. // //encore:api auth method=DELETE path=/videochat/rooms/:roomName func DeleteRoom(ctx context.Context, roomName string) error { if _, ok := auth.UserID(); !ok { return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"} } if err := validateRoomName(roomName); err != nil { return err } client, _, err := liveKitClient() if err != nil { return err } ctx, cancel := context.WithTimeout(ctx, apiTimeout) defer cancel() room, err := getOwnedBroadcast(ctx, client, roomName) if err != nil { return err } metadata, _ := readBroadcast(room) ownerID := metadata.OwnerID if ownerID == "" { uid, _ := auth.UserID() ownerID = string(uid) } _, err = client.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: room.Name}) if err != nil { return liveKitError(err) } _ = logBroadcastSession(ctx, ownerID, room.Sid, room.Name, "CLOSE") return nil }