feat: include broadcaster users and avatars in room discovery and listing
This commit is contained in:
@@ -50,18 +50,42 @@ func OpenRoom(ctx context.Context, roomName string) (*Room, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Multiple enabled owners with the same name are ambiguous: do not pick one.
|
// Multiple enabled owners with the same name are ambiguous: do not pick one.
|
||||||
var owner string
|
rows, err := tx.Query(ctx, `
|
||||||
var count int
|
SELECT DISTINCT p.user_id::text
|
||||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MIN(user_id::text), '') FROM personal_setup WHERE can_broadcast = TRUE AND room_name = $1`, roomName).Scan(&count, &owner)
|
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 = $1)
|
||||||
|
OR (p.member_type = 'broadcaster' AND (
|
||||||
|
s.room_name = $1
|
||||||
|
OR p.display_name = $1
|
||||||
|
OR p.user_id::text = $1
|
||||||
|
OR $1 = ('broadcaster-' || SUBSTRING(p.user_id::text, 1, 8))
|
||||||
|
OR $1 = (p.display_name || '-' || SUBSTRING(p.user_id::text, 1, 8))
|
||||||
|
))
|
||||||
|
`, roomName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch configured room")
|
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch configured room")
|
||||||
}
|
}
|
||||||
if count == 0 {
|
defer rows.Close()
|
||||||
|
|
||||||
|
var owners []string
|
||||||
|
for rows.Next() {
|
||||||
|
var o string
|
||||||
|
if err := rows.Scan(&o); err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to read configured room owner")
|
||||||
|
}
|
||||||
|
owners = append(owners, o)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch configured room")
|
||||||
|
}
|
||||||
|
if len(owners) == 0 {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "no enabled broadcast setup for this room"}
|
return nil, &errs.Error{Code: errs.NotFound, Message: "no enabled broadcast setup for this room"}
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if len(owners) > 1 {
|
||||||
return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is configured for more than one owner"}
|
return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is configured for more than one owner"}
|
||||||
}
|
}
|
||||||
|
owner := owners[0]
|
||||||
for _, room := range result.Rooms {
|
for _, room := range result.Rooms {
|
||||||
if metadata, ok := readBroadcast(room); ok && metadata.OwnerID == owner && metadata.DisplayName == roomName {
|
if metadata, ok := readBroadcast(room); ok && metadata.OwnerID == owner && metadata.DisplayName == roomName {
|
||||||
return roomResponse(room), nil
|
return roomResponse(room), nil
|
||||||
|
|||||||
@@ -127,3 +127,50 @@ func TestOpenRoomCreatesAndReusesConfiguredRoom(t *testing.T) {
|
|||||||
t.Fatalf("disabled room was created: %v", err)
|
t.Fatalf("disabled room was created: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenRoomBroadcasterUser(t *testing.T) {
|
||||||
|
useTestProfilesDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const broadcasterID = "00000000-0000-4000-8000-000000000020"
|
||||||
|
if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email, member_type) VALUES ($1, 'Live Artist', 'artist@example.invalid', 'broadcaster')`, broadcasterID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdRoom *livekit.CreateRoomRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/twirp/livekit.RoomService/ListRooms":
|
||||||
|
payload, _ := proto.Marshal(&livekit.ListRoomsResponse{Rooms: nil})
|
||||||
|
w.Header().Set("Content-Type", "application/protobuf")
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
case "/twirp/livekit.RoomService/CreateRoom":
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req livekit.CreateRoomRequest
|
||||||
|
_ = proto.Unmarshal(body, &req)
|
||||||
|
createdRoom = &req
|
||||||
|
room := &livekit.Room{Sid: "sid-artist", Name: req.Name, Metadata: req.Metadata}
|
||||||
|
payload, _ := proto.Marshal(room)
|
||||||
|
w.Header().Set("Content-Type", "application/protobuf")
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
original := secrets
|
||||||
|
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
|
||||||
|
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
|
||||||
|
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
|
||||||
|
|
||||||
|
room, err := OpenRoom(ctx, "Live Artist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error opening broadcaster room: %v", err)
|
||||||
|
}
|
||||||
|
if room.OwnerID != broadcasterID {
|
||||||
|
t.Fatalf("expected owner %s, got %s", broadcasterID, room.OwnerID)
|
||||||
|
}
|
||||||
|
if room.Name != "Live Artist" {
|
||||||
|
t.Fatalf("expected name 'Live Artist', got %s", room.Name)
|
||||||
|
}
|
||||||
|
if createdRoom == nil || createdRoom.Name != "Live Artist" {
|
||||||
|
t.Fatalf("expected createdRoom to be 'Live Artist', got %+v", createdRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Room struct {
|
|||||||
NumParticipants uint32 `json:"num_participants"`
|
NumParticipants uint32 `json:"num_participants"`
|
||||||
MaxParticipants uint32 `json:"max_participants"`
|
MaxParticipants uint32 `json:"max_participants"`
|
||||||
CreationTime int64 `json:"creation_time"`
|
CreationTime int64 `json:"creation_time"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListRoomsResponse struct {
|
type ListRoomsResponse struct {
|
||||||
@@ -109,22 +110,67 @@ func ListRooms(ctx context.Context) (*ListRoomsResponse, error) {
|
|||||||
|
|
||||||
func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) {
|
func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) {
|
||||||
rows, err := profilesDB.Query(ctx, `
|
rows, err := profilesDB.Query(ctx, `
|
||||||
SELECT user_id::text, room_name
|
SELECT
|
||||||
FROM personal_setup
|
p.user_id::text,
|
||||||
WHERE can_broadcast = TRUE AND room_name <> ''
|
p.display_name,
|
||||||
ORDER BY room_name, user_id
|
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 {
|
if err != nil {
|
||||||
return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms")
|
return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
rooms := make([]*Room, 0)
|
rooms := make([]*Room, 0)
|
||||||
|
usedNames := make(map[string]bool)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
room := &Room{Mode: "broadcast"}
|
var (
|
||||||
if err := rows.Scan(&room.OwnerID, &room.Name); err != nil {
|
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")
|
return nil, errs.WrapCode(err, errs.Internal, "failed to read configured broadcast room")
|
||||||
}
|
}
|
||||||
room.DisplayName = room.Name
|
|
||||||
|
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)
|
rooms = append(rooms, room)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -137,12 +183,24 @@ func mergeBroadcastRooms(active, configured []*Room) []*Room {
|
|||||||
type ownerTitle struct{ owner, title string }
|
type ownerTitle struct{ owner, title string }
|
||||||
names := make(map[string]bool, len(active))
|
names := make(map[string]bool, len(active))
|
||||||
titles := make(map[ownerTitle]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 {
|
for _, room := range active {
|
||||||
names[room.Name] = true
|
names[room.Name] = true
|
||||||
titles[ownerTitle{room.OwnerID, room.DisplayName}] = 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 {
|
for _, room := range configured {
|
||||||
if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] {
|
if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] && !titles[ownerTitle{room.OwnerID, room.DisplayName}] {
|
||||||
active = append(active, room)
|
active = append(active, room)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,3 +97,49 @@ func TestMergeBroadcastRoomsMatchesTechnicalName(t *testing.T) {
|
|||||||
t.Fatal("empty list must stay an empty JSON array")
|
t.Fatal("empty list must stay an empty JSON array")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListRoomsIncludesBroadcasterUsers(t *testing.T) {
|
||||||
|
useTestProfilesDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const broadcasterID = "00000000-0000-4000-8000-000000000010"
|
||||||
|
if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email, member_type, avatar_url) VALUES ($1, 'DJ Broadcaster', 'dj@example.invalid', 'broadcaster', 'https://example.com/dj.jpg')`, broadcasterID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := proto.Marshal(&livekit.ListRoomsResponse{Rooms: []*livekit.Room{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/protobuf")
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
original := secrets
|
||||||
|
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
|
||||||
|
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
|
||||||
|
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
|
||||||
|
|
||||||
|
result, err := ListRooms(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, room := range result.Rooms {
|
||||||
|
if room.OwnerID == broadcasterID {
|
||||||
|
found = true
|
||||||
|
if room.Active {
|
||||||
|
t.Errorf("broadcaster room without live stream should be inactive")
|
||||||
|
}
|
||||||
|
if room.DisplayName != "DJ Broadcaster" {
|
||||||
|
t.Errorf("expected display name 'DJ Broadcaster', got %q", room.DisplayName)
|
||||||
|
}
|
||||||
|
if room.AvatarURL != "https://example.com/dj.jpg" {
|
||||||
|
t.Errorf("expected avatar_url 'https://example.com/dj.jpg', got %q", room.AvatarURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected broadcaster user to be included in rooms list: %+v", result.Rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user