From 7155b8111609ecd01f66e73ce79f302f839012e3 Mon Sep 17 00:00:00 2001 From: fabio Date: Thu, 8 Oct 2026 10:48:13 +0200 Subject: [PATCH] feat: include broadcaster users and avatars in room discovery and listing --- videochat/open_room.go | 34 ++++++++++++++--- videochat/open_room_test.go | 47 +++++++++++++++++++++++ videochat/rooms.go | 74 +++++++++++++++++++++++++++++++++---- videochat/rooms_test.go | 46 +++++++++++++++++++++++ 4 files changed, 188 insertions(+), 13 deletions(-) diff --git a/videochat/open_room.go b/videochat/open_room.go index 22c21d5..2db052a 100644 --- a/videochat/open_room.go +++ b/videochat/open_room.go @@ -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. - var owner string - var count int - 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) + rows, err := tx.Query(ctx, ` + SELECT DISTINCT p.user_id::text + 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 { 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"} } - if count != 1 { + if len(owners) > 1 { 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 { if metadata, ok := readBroadcast(room); ok && metadata.OwnerID == owner && metadata.DisplayName == roomName { return roomResponse(room), nil diff --git a/videochat/open_room_test.go b/videochat/open_room_test.go index 0a2d4e3..9c8b7a6 100644 --- a/videochat/open_room_test.go +++ b/videochat/open_room_test.go @@ -127,3 +127,50 @@ func TestOpenRoomCreatesAndReusesConfiguredRoom(t *testing.T) { 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) + } +} diff --git a/videochat/rooms.go b/videochat/rooms.go index 2993e73..a510a9e 100644 --- a/videochat/rooms.go +++ b/videochat/rooms.go @@ -29,6 +29,7 @@ type Room struct { NumParticipants uint32 `json:"num_participants"` MaxParticipants uint32 `json:"max_participants"` CreationTime int64 `json:"creation_time"` + AvatarURL string `json:"avatar_url,omitempty"` } type ListRoomsResponse struct { @@ -109,22 +110,67 @@ func ListRooms(ctx context.Context) (*ListRoomsResponse, error) { func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) { rows, err := profilesDB.Query(ctx, ` - SELECT user_id::text, room_name - FROM personal_setup - WHERE can_broadcast = TRUE AND room_name <> '' - ORDER BY room_name, user_id + 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() { - room := &Room{Mode: "broadcast"} - if err := rows.Scan(&room.OwnerID, &room.Name); err != nil { + 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") } - 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) } if err := rows.Err(); err != nil { @@ -137,12 +183,24 @@ 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}] { + if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] && !titles[ownerTitle{room.OwnerID, room.DisplayName}] { active = append(active, room) } } diff --git a/videochat/rooms_test.go b/videochat/rooms_test.go index d54640b..b1a4013 100644 --- a/videochat/rooms_test.go +++ b/videochat/rooms_test.go @@ -97,3 +97,49 @@ func TestMergeBroadcastRoomsMatchesTechnicalName(t *testing.T) { 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) + } +}