package watchtogether import ( "context" "crypto/rand" "errors" "fmt" "math" "strings" "sync" "time" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" "github.com/google/uuid" ) var ( ErrRoomClosed = errors.New("watch together room is closed") ErrRoomForbidden = errors.New("watch together room action forbidden") ErrInvalidJoinRequest = errors.New("watch together join request is invalid") ErrSessionMismatch = errors.New("watch together playback session mismatch") ErrTransportNotAllowed = errors.New("watch together transport action not allowed") ErrConnectionNotAttached = errors.New("watch together session is not attached") ErrInvalidSelection = errors.New("watch together selection is invalid") ErrSuggestionNotFound = errors.New("watch together suggestion not found") ErrDuplicateVote = errors.New("watch together already voted") ErrNotVoted = errors.New("watch together not voted") ) const ( defaultTransportLead = 500 * time.Millisecond minTransportLead = 350 * time.Millisecond ) type RoomConnection interface { WriteJSON(v any) error Close() error } type RoomStore interface { CreateRoom(ctx context.Context, room Room) (*Room, error) GetRoomByID(ctx context.Context, roomID string) (*Room, error) GetRoomByCode(ctx context.Context, code string) (*Room, error) GetRoomByJoinToken(ctx context.Context, joinToken string) (*Room, error) UpdatePolicy(ctx context.Context, roomID string, policy GuestControlPolicy, generation int64, expectedGeneration int64) (*Room, error) UpdateAnchor( ctx context.Context, roomID string, positionSeconds float64, isPaused bool, playbackState RoomPlaybackState, resumeOnReady bool, anchorUpdatedAt time.Time, generation int64, expectedGeneration int64, ) (*Room, error) CloseRoom(ctx context.Context, roomID string, closedAt time.Time) (*Room, error) UpdateSelection( ctx context.Context, roomID string, selection SelectItemInput, phase RoomPhase, playbackState RoomPlaybackState, resumeOnReady bool, anchorPosition float64, isPaused bool, anchorUpdatedAt time.Time, selectionRevision int64, generation int64, expectedGeneration int64, ) (*Room, error) } type RoomSessionLookup interface { GetSession(sessionID string) (*playback.Session, error) } type MediaFileLookup interface { GetByID(ctx context.Context, id int) (*models.MediaFile, error) } type RoomCommandDispatcher interface { DispatchToSession( command playback.CommandEnvelope, deadline time.Duration, fallback func(), ) playback.CommandDispatchResult } type WatchTogetherSelectionResolver interface { ResolveSelection(ctx context.Context, userID int, profileID string, input SelectItemInput) (*ResolvedSelection, error) } type Registration struct { roomID string memberKey string connection RoomConnection } type memberState struct { userID int profileID string sessionID string connection RoomConnection isReady bool isBuffering bool ignoreWait bool lastPingMS int64 } type liveRoom struct { room Room members map[string]*memberState hostCloseTimer *time.Timer } type snapshotDispatch struct { conn RoomConnection payload map[string]any } type commandDispatch struct { conn RoomConnection payload map[string]any memberKey string } type Service struct { repo RoomStore suggestions SuggestionStore sessions RoomSessionLookup files MediaFileLookup dispatcher RoomCommandDispatcher selectionResolver WatchTogetherSelectionResolver hostDisconnectTTL time.Duration now func() time.Time mu sync.Mutex rooms map[string]*liveRoom } func NewService( repo RoomStore, sessions RoomSessionLookup, files MediaFileLookup, dispatcher RoomCommandDispatcher, selectionResolver WatchTogetherSelectionResolver, suggestions SuggestionStore, ) *Service { return &Service{ repo: repo, suggestions: suggestions, sessions: sessions, files: files, dispatcher: dispatcher, selectionResolver: selectionResolver, hostDisconnectTTL: 15 * time.Second, now: func() time.Time { return time.Now().UTC() }, rooms: make(map[string]*liveRoom), } } func (s *Service) CreateRoom(ctx context.Context, input CreateRoomInput) (*Room, error) { if s == nil || s.repo == nil { return nil, fmt.Errorf("watch together service unavailable") } now := s.now() selectionMode := input.SelectionMode if selectionMode != RoomSelectionModeVote { selectionMode = RoomSelectionModeHostPick } room := Room{ ID: uuid.NewString(), Code: randomToken(8), JoinToken: randomToken(24), HostUserID: input.HostUserID, HostProfileID: input.HostProfileID, Phase: RoomPhaseLobby, PlaybackState: RoomPlaybackStateIdle, ResumeOnReady: false, SelectionMode: selectionMode, SelectionRevision: 0, GuestControlPolicy: GuestControlPolicyHostOnly, AnchorPositionSeconds: 0, IsPaused: true, AnchorUpdatedAt: now, Generation: 1, CreatedAt: now, } created, err := s.repo.CreateRoom(ctx, room) if err != nil { return nil, err } s.mu.Lock() s.rooms[created.ID] = &liveRoom{ room: *created, members: make(map[string]*memberState), } s.mu.Unlock() return created, nil } func (s *Service) JoinRoom(ctx context.Context, input JoinInput) (*Room, error) { if s == nil || s.repo == nil { return nil, fmt.Errorf("watch together service unavailable") } switch { case strings.TrimSpace(input.JoinToken) != "": return s.loadRoom(ctx, func() (*Room, error) { return s.repo.GetRoomByJoinToken(ctx, strings.TrimSpace(input.JoinToken)) }) case strings.TrimSpace(input.Code) != "": return s.loadRoom(ctx, func() (*Room, error) { return s.repo.GetRoomByCode(ctx, strings.TrimSpace(input.Code)) }) default: return nil, ErrInvalidJoinRequest } } func (s *Service) GetRoom(ctx context.Context, roomID string) (*Room, error) { return s.loadRoom(ctx, func() (*Room, error) { return s.repo.GetRoomByID(ctx, roomID) }) } func (s *Service) Snapshot(ctx context.Context, roomID string, userID int, profileID string) (Snapshot, error) { _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() defer s.mu.Unlock() return s.buildSnapshotLocked(live, userID, profileID), nil } func (s *Service) Connect( ctx context.Context, roomID string, userID int, profileID string, conn RoomConnection, ) (*Registration, Snapshot, error) { room, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return nil, Snapshot{}, err } memberKey := buildMemberKey(userID, profileID) s.mu.Lock() current := live.members[memberKey] var previousConn RoomConnection if current == nil { current = &memberState{userID: userID, profileID: profileID} live.members[memberKey] = current } else if current.connection != nil && current.connection != conn { previousConn = current.connection } current.connection = conn if room.HostUserID == userID && room.HostProfileID == profileID && live.hostCloseTimer != nil { live.hostCloseTimer.Stop() live.hostCloseTimer = nil } snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() if previousConn != nil { _ = previousConn.Close() } s.runDispatches(dispatches) return &Registration{roomID: roomID, memberKey: memberKey, connection: conn}, snapshot, nil } func (s *Service) Disconnect(reg *Registration, explicitLeave bool) { if s == nil || reg == nil { return } var dispatches []snapshotDispatch s.mu.Lock() live := s.rooms[reg.roomID] if live == nil { s.mu.Unlock() return } member := live.members[reg.memberKey] if member == nil || member.connection != reg.connection { s.mu.Unlock() return } isHost := member.userID == live.room.HostUserID && member.profileID == live.room.HostProfileID member.connection = nil member.sessionID = "" delete(live.members, reg.memberKey) if isHost { if explicitLeave { s.mu.Unlock() _ = s.CloseRoom(context.Background(), reg.roomID, member.userID, member.profileID) return } if live.hostCloseTimer != nil { live.hostCloseTimer.Stop() } roomID := reg.roomID hostUserID := live.room.HostUserID hostProfileID := live.room.HostProfileID live.hostCloseTimer = time.AfterFunc(s.hostDisconnectTTL, func() { _ = s.CloseRoom(context.Background(), roomID, hostUserID, hostProfileID) }) } dispatches = s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) } func (s *Service) AttachSession( ctx context.Context, roomID string, userID int, profileID string, sessionID string, ) (Snapshot, error) { room, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } session, err := s.sessions.GetSession(sessionID) if err != nil { return Snapshot{}, err } if session.UserID != userID || session.ProfileID != profileID { return Snapshot{}, ErrSessionMismatch } if err := s.validateSessionContent(ctx, room, session); err != nil { return Snapshot{}, err } s.mu.Lock() member := live.members[buildMemberKey(userID, profileID)] if member == nil || member.connection == nil { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } member.sessionID = sessionID member.isReady = false member.isBuffering = live.room.Phase == RoomPhasePlaying var commandDispatches []commandDispatch if live.room.Phase == RoomPhasePlaying { if live.room.PlaybackState == RoomPlaybackStatePlaying && s.activeParticipantCountLocked(live) > 1 { position := s.expectedPositionLocked(live) commandDispatches, _ = s.enterWaitingLocked(live, position, true) expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted } else { commandDispatches = s.syncMemberToRoomLocked(live, sessionID) } } snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandleTransportRequest( ctx context.Context, roomID string, userID int, profileID string, request TransportRequest, ) (Snapshot, error) { _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() member := live.members[buildMemberKey(userID, profileID)] if member == nil || member.connection == nil || member.sessionID == "" { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } if err := s.ensureTransportAllowedLocked(live, userID, profileID, request.Action); err != nil { s.mu.Unlock() return Snapshot{}, err } position := live.room.AnchorPositionSeconds if request.PositionSeconds != nil { position = math.Max(0, *request.PositionSeconds) } else if !live.room.IsPaused { position = s.expectedPositionLocked(live) } now := s.now() live.room.AnchorPositionSeconds = position live.room.AnchorUpdatedAt = now commandDispatches := []commandDispatch(nil) executeAt := now.Add(s.highestPingLocked(live)) switch request.Action { case TransportActionPlay: live.room.ResumeOnReady = true live.room.IsPaused = false live.room.PlaybackState = RoomPlaybackStatePlaying commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionPlay, position, executeAt, ) case TransportActionPause: live.room.ResumeOnReady = false live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStatePaused commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionPause, position, executeAt, ) case TransportActionSeek: live.room.ResumeOnReady = !request.IsPaused live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStateWaiting s.resetMemberReadinessLocked(live, false) commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionSeek, position, executeAt, ) default: s.mu.Unlock() return Snapshot{}, ErrTransportNotAllowed } expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } } s.mu.Unlock() if errors.Is(updateErr, ErrRoomStateConflict) { return s.Snapshot(ctx, roomID, userID, profileID) } return Snapshot{}, updateErr } live.room = *persisted snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandleStateReport( ctx context.Context, roomID string, userID int, profileID string, report StateReport, ) (Snapshot, error) { _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } var dispatches []snapshotDispatch var correctionDispatches []commandDispatch s.mu.Lock() member := live.members[buildMemberKey(userID, profileID)] if member == nil || member.connection == nil { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } if member.sessionID == "" || member.sessionID != report.SessionID { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } isHost := userID == live.room.HostUserID && profileID == live.room.HostProfileID expected := s.expectedPositionLocked(live) pauseMismatch := report.IsPaused != live.room.IsPaused drift := math.Abs(report.PositionSeconds - expected) snapshot := s.buildSnapshotLocked(live, userID, profileID) if isHost && (pauseMismatch || drift > 1.5) { live.room.AnchorPositionSeconds = math.Max(0, report.PositionSeconds) live.room.IsPaused = report.IsPaused live.room.AnchorUpdatedAt = s.now() expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot = s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted snapshot = s.buildSnapshotLocked(live, userID, profileID) dispatches = s.prepareSnapshotDispatchesLocked(live) } else if !isHost && (pauseMismatch || drift > 1.0) { correctionDispatches = s.targetedCommandDispatchesLocked(live, report.SessionID, TransportCommand{ CommandID: uuid.NewString(), SelectionRevision: live.room.SelectionRevision, Action: func() TransportAction { if live.room.PlaybackState == RoomPlaybackStatePlaying { return TransportActionPlay } return TransportActionPause }(), PositionSeconds: math.Max(0, expectedPosition(live.room, s.now())), ExecuteAt: s.now().Add(s.highestPingLocked(live)).UTC().Format(time.RFC3339Nano), IssuedAt: s.now().UTC().Format(time.RFC3339Nano), PlaybackState: live.room.PlaybackState, }) } s.mu.Unlock() if isHost && (pauseMismatch || drift > 1.5) { s.runDispatches(dispatches) return snapshot, nil } if len(correctionDispatches) > 0 { s.runCommandDispatches(correctionDispatches) } return snapshot, nil } func (s *Service) AttachSessionForConnection( ctx context.Context, reg *Registration, userID int, profileID string, sessionID string, ) (Snapshot, error) { if reg == nil { return Snapshot{}, ErrRoomForbidden } room, live, err := s.getOrLoadLiveRoom(ctx, reg.roomID) if err != nil { return Snapshot{}, err } session, err := s.sessions.GetSession(sessionID) if err != nil { return Snapshot{}, err } if session.UserID != userID || session.ProfileID != profileID { return Snapshot{}, ErrSessionMismatch } if err := s.validateSessionContent(ctx, room, session); err != nil { return Snapshot{}, err } s.mu.Lock() member := live.members[reg.memberKey] if member == nil || member.connection == nil || member.connection != reg.connection { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } member.sessionID = sessionID member.isReady = false member.isBuffering = live.room.Phase == RoomPhasePlaying var commandDispatches []commandDispatch if live.room.Phase == RoomPhasePlaying { if live.room.PlaybackState == RoomPlaybackStatePlaying && s.activeParticipantCountLocked(live) > 1 { position := s.expectedPositionLocked(live) commandDispatches, _ = s.enterWaitingLocked(live, position, true) expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, reg.roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted } else { commandDispatches = s.syncMemberToRoomLocked(live, sessionID) } } snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandleTransportRequestForConnection( ctx context.Context, reg *Registration, userID int, profileID string, request TransportRequest, ) (Snapshot, error) { if reg == nil { return Snapshot{}, ErrRoomForbidden } _, live, err := s.getOrLoadLiveRoom(ctx, reg.roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() member := live.members[reg.memberKey] if member == nil || member.connection == nil || member.connection != reg.connection || member.sessionID == "" { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } if err := s.ensureTransportAllowedLocked(live, userID, profileID, request.Action); err != nil { s.mu.Unlock() return Snapshot{}, err } position := live.room.AnchorPositionSeconds if request.PositionSeconds != nil { position = math.Max(0, *request.PositionSeconds) } else if !live.room.IsPaused { position = s.expectedPositionLocked(live) } now := s.now() live.room.AnchorPositionSeconds = position live.room.AnchorUpdatedAt = now commandDispatches := []commandDispatch(nil) executeAt := now.Add(s.highestPingLocked(live)) switch request.Action { case TransportActionPlay: live.room.ResumeOnReady = true live.room.IsPaused = false live.room.PlaybackState = RoomPlaybackStatePlaying commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionPlay, position, executeAt, ) case TransportActionPause: live.room.ResumeOnReady = false live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStatePaused commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionPause, position, executeAt, ) case TransportActionSeek: live.room.ResumeOnReady = !request.IsPaused live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStateWaiting s.resetMemberReadinessLocked(live, false) commandDispatches = s.transportCommandDispatchesLocked( live, TransportActionSeek, position, executeAt, ) default: s.mu.Unlock() return Snapshot{}, ErrTransportNotAllowed } expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, reg.roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } } s.mu.Unlock() if errors.Is(updateErr, ErrRoomStateConflict) { return s.Snapshot(ctx, reg.roomID, userID, profileID) } return Snapshot{}, updateErr } live.room = *persisted snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandleStateReportForConnection( ctx context.Context, reg *Registration, userID int, profileID string, report StateReport, ) (Snapshot, error) { if reg == nil { return Snapshot{}, ErrRoomForbidden } _, live, err := s.getOrLoadLiveRoom(ctx, reg.roomID) if err != nil { return Snapshot{}, err } var dispatches []snapshotDispatch var correctionDispatches []commandDispatch s.mu.Lock() member := live.members[reg.memberKey] if member == nil || member.connection == nil || member.connection != reg.connection { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } if member.sessionID == "" || member.sessionID != report.SessionID { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } isHost := userID == live.room.HostUserID && profileID == live.room.HostProfileID expected := s.expectedPositionLocked(live) pauseMismatch := report.IsPaused != live.room.IsPaused drift := math.Abs(report.PositionSeconds - expected) snapshot := s.buildSnapshotLocked(live, userID, profileID) if isHost && (pauseMismatch || drift > 1.5) { live.room.AnchorPositionSeconds = math.Max(0, report.PositionSeconds) live.room.IsPaused = report.IsPaused live.room.AnchorUpdatedAt = s.now() expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, reg.roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot = s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted snapshot = s.buildSnapshotLocked(live, userID, profileID) dispatches = s.prepareSnapshotDispatchesLocked(live) } else if !isHost && (pauseMismatch || drift > 1.0) { correctionDispatches = s.targetedCommandDispatchesLocked(live, report.SessionID, TransportCommand{ CommandID: uuid.NewString(), SelectionRevision: live.room.SelectionRevision, Action: func() TransportAction { if live.room.PlaybackState == RoomPlaybackStatePlaying { return TransportActionPlay } return TransportActionPause }(), PositionSeconds: math.Max(0, expectedPosition(live.room, s.now())), ExecuteAt: s.now().Add(s.highestPingLocked(live)).UTC().Format(time.RFC3339Nano), IssuedAt: s.now().UTC().Format(time.RFC3339Nano), PlaybackState: live.room.PlaybackState, }) } s.mu.Unlock() if isHost && (pauseMismatch || drift > 1.5) { s.runDispatches(dispatches) return snapshot, nil } if len(correctionDispatches) > 0 { s.runCommandDispatches(correctionDispatches) } return snapshot, nil } func (s *Service) HandleReadyForConnection( ctx context.Context, reg *Registration, userID int, profileID string, report StateReport, ) (Snapshot, error) { if reg == nil { return Snapshot{}, ErrRoomForbidden } _, live, err := s.getOrLoadLiveRoom(ctx, reg.roomID) if err != nil { return Snapshot{}, err } var dispatches []snapshotDispatch var commandDispatches []commandDispatch s.mu.Lock() member := live.members[reg.memberKey] if member == nil || member.connection == nil || member.connection != reg.connection { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } if member.sessionID == "" || member.sessionID != report.SessionID { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } member.isReady = true member.isBuffering = false snapshot := s.buildSnapshotLocked(live, userID, profileID) if live.room.PlaybackState != RoomPlaybackStateWaiting || !s.allParticipantsReadyLocked(live) { dispatches = s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) return snapshot, nil } live.room.AnchorPositionSeconds = math.Max(0, live.room.AnchorPositionSeconds) live.room.AnchorUpdatedAt = s.now() action := TransportActionPause if live.room.ResumeOnReady { live.room.IsPaused = false live.room.PlaybackState = RoomPlaybackStatePlaying action = TransportActionPlay } else { live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStatePaused action = TransportActionPause } expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, reg.roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot = s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted snapshot = s.buildSnapshotLocked(live, userID, profileID) dispatches = s.prepareSnapshotDispatchesLocked(live) commandDispatches = s.transportCommandDispatchesLocked( live, action, live.room.AnchorPositionSeconds, s.now().Add(s.highestPingLocked(live)), ) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandleBufferingForConnection( ctx context.Context, reg *Registration, userID int, profileID string, report StateReport, ) (Snapshot, error) { if reg == nil { return Snapshot{}, ErrRoomForbidden } _, live, err := s.getOrLoadLiveRoom(ctx, reg.roomID) if err != nil { return Snapshot{}, err } var dispatches []snapshotDispatch var commandDispatches []commandDispatch s.mu.Lock() member := live.members[reg.memberKey] if member == nil || member.connection == nil || member.connection != reg.connection { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } if member.sessionID == "" || member.sessionID != report.SessionID { s.mu.Unlock() return Snapshot{}, ErrConnectionNotAttached } member.isBuffering = true member.isReady = false if live.room.Phase != RoomPhasePlaying { snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } if live.room.PlaybackState != RoomPlaybackStateWaiting { commandDispatches, _ = s.enterWaitingLocked( live, math.Max(0, report.PositionSeconds), live.room.PlaybackState == RoomPlaybackStatePlaying || live.room.ResumeOnReady, ) expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdateAnchor( ctx, live.room.ID, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorUpdatedAt, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, reg.roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted } snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches = s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) s.runCommandDispatches(commandDispatches) return snapshot, nil } func (s *Service) HandlePingForConnection( _ context.Context, reg *Registration, userID int, profileID string, pingMS int64, ) error { if reg == nil { return ErrRoomForbidden } s.mu.Lock() defer s.mu.Unlock() live := s.rooms[reg.roomID] if live == nil { return ErrRoomNotFound } member := live.members[buildMemberKey(userID, profileID)] if member == nil || member.connection == nil || member.connection != reg.connection { return ErrRoomForbidden } if pingMS > 0 { member.lastPingMS = pingMS } return nil } func (s *Service) UpdatePolicy( ctx context.Context, roomID string, userID int, profileID string, policy GuestControlPolicy, ) (Snapshot, error) { if policy != GuestControlPolicyHostOnly && policy != GuestControlPolicyGuestPlayPause { return Snapshot{}, ErrTransportNotAllowed } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() if live.room.HostUserID != userID || live.room.HostProfileID != profileID { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } live.room.GuestControlPolicy = policy expectedGeneration := live.room.Generation live.room.Generation++ persisted, updateErr := s.repo.UpdatePolicy( ctx, roomID, policy, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) return snapshot, nil } func (s *Service) SelectItem( ctx context.Context, roomID string, userID int, profileID string, input SelectItemInput, ) (Snapshot, error) { if strings.TrimSpace(input.ContentID) == "" { return Snapshot{}, ErrInvalidSelection } if s.selectionResolver == nil { return Snapshot{}, fmt.Errorf("watch together selection resolver unavailable") } resolved, err := s.selectionResolver.ResolveSelection(ctx, userID, profileID, input) if err != nil { if errors.Is(err, catalog.ErrWatchTargetNotPlayable) { return Snapshot{}, ErrInvalidSelection } return Snapshot{}, err } if resolved == nil || strings.TrimSpace(resolved.ContentID) == "" { return Snapshot{}, ErrInvalidSelection } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() if live.room.HostUserID != userID || live.room.HostProfileID != profileID { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } if live.room.Phase == RoomPhaseEnded { s.mu.Unlock() return Snapshot{}, ErrRoomClosed } now := s.now() live.room.Phase = RoomPhasePlaying live.room.PlaybackState = RoomPlaybackStateWaiting live.room.ResumeOnReady = true live.room.SelectedContentID = &resolved.ContentID live.room.SelectedFileID = resolved.FileID live.room.SelectedLibraryID = resolved.LibraryID live.room.AnchorPositionSeconds = 0 live.room.IsPaused = true live.room.AnchorUpdatedAt = now s.resetMemberReadinessLocked(live, false) expectedGeneration := live.room.Generation live.room.SelectionRevision++ live.room.Generation++ persisted, updateErr := s.repo.UpdateSelection( ctx, roomID, SelectItemInput{ ContentID: resolved.ContentID, FileID: resolved.FileID, LibraryID: resolved.LibraryID, }, live.room.Phase, live.room.PlaybackState, live.room.ResumeOnReady, live.room.AnchorPositionSeconds, live.room.IsPaused, live.room.AnchorUpdatedAt, live.room.SelectionRevision, live.room.Generation, expectedGeneration, ) if updateErr != nil { if errors.Is(updateErr, ErrRoomStateConflict) { if refreshed, refreshErr := s.repo.GetRoomByID(ctx, roomID); refreshErr == nil && refreshed != nil { live.room = *refreshed } snapshot := s.buildSnapshotLocked(live, userID, profileID) s.mu.Unlock() return snapshot, nil } s.mu.Unlock() return Snapshot{}, updateErr } live.room = *persisted snapshot := s.buildSnapshotLocked(live, userID, profileID) dispatches := s.prepareSnapshotDispatchesLocked(live) s.mu.Unlock() s.runDispatches(dispatches) return snapshot, nil } func (s *Service) CloseRoom(ctx context.Context, roomID string, userID int, profileID string) error { if s == nil || s.repo == nil { return fmt.Errorf("watch together service unavailable") } var roomForAuth *Room s.mu.Lock() live := s.rooms[roomID] if live != nil { roomCopy := live.room roomForAuth = &roomCopy } s.mu.Unlock() if roomForAuth == nil { var err error roomForAuth, err = s.GetRoom(ctx, roomID) if err != nil { return err } } if roomForAuth.HostUserID != userID || roomForAuth.HostProfileID != profileID { return ErrRoomForbidden } closedAt := s.now() room, err := s.repo.CloseRoom(ctx, roomID, closedAt) if err != nil { return err } var dispatches []snapshotDispatch s.mu.Lock() live = s.rooms[roomID] if live != nil { live.room = *room dispatches = s.prepareRoomClosedDispatchesLocked(live) if live.hostCloseTimer != nil { live.hostCloseTimer.Stop() } delete(s.rooms, roomID) } s.mu.Unlock() s.runDispatches(dispatches) return nil } func (s *Service) loadRoom(ctx context.Context, load func() (*Room, error)) (*Room, error) { room, err := load() if err != nil { return nil, err } if room.Phase == RoomPhaseEnded { return nil, ErrRoomClosed } return room, nil } func (s *Service) getOrLoadLiveRoom(ctx context.Context, roomID string) (*Room, *liveRoom, error) { s.mu.Lock() if live := s.rooms[roomID]; live != nil { roomCopy := live.room s.mu.Unlock() if roomCopy.Phase == RoomPhaseEnded { return nil, nil, ErrRoomClosed } return &roomCopy, live, nil } s.mu.Unlock() room, err := s.GetRoom(ctx, roomID) if err != nil { return nil, nil, err } s.mu.Lock() defer s.mu.Unlock() if live := s.rooms[roomID]; live != nil { roomCopy := live.room return &roomCopy, live, nil } live := &liveRoom{ room: *room, members: make(map[string]*memberState), } s.rooms[roomID] = live return room, live, nil } func (s *Service) ensureTransportAllowedLocked( live *liveRoom, userID int, profileID string, action TransportAction, ) error { if live.room.Phase != RoomPhasePlaying { return ErrTransportNotAllowed } isHost := live.room.HostUserID == userID && live.room.HostProfileID == profileID if isHost { return nil } if live.room.GuestControlPolicy == GuestControlPolicyGuestPlayPause && (action == TransportActionPlay || action == TransportActionPause) { return nil } return ErrTransportNotAllowed } func (s *Service) validateSessionContent(ctx context.Context, room *Room, session *playback.Session) error { if room == nil || session == nil || s.files == nil { return ErrSessionMismatch } if room.Phase != RoomPhasePlaying || room.SelectedContentID == nil || *room.SelectedContentID == "" { return ErrSessionMismatch } file, err := s.files.GetByID(ctx, session.MediaFileID) if err != nil { return err } if file == nil { return ErrSessionMismatch } if file.ContentID != *room.SelectedContentID && file.EpisodeID != *room.SelectedContentID { return ErrSessionMismatch } if room.SelectedFileID != nil && session.MediaFileID != *room.SelectedFileID { return ErrSessionMismatch } return nil } func (s *Service) buildSnapshotLocked(live *liveRoom, userID int, profileID string) Snapshot { member := live.members[buildMemberKey(userID, profileID)] isHost := live.room.HostUserID == userID && live.room.HostProfileID == profileID canControl := isHost || (live.room.Phase == RoomPhasePlaying && live.room.GuestControlPolicy == GuestControlPolicyGuestPlayPause) invitePath := "" if isHost { invitePath = fmt.Sprintf("/rooms/join?token=%s", live.room.JoinToken) } return Snapshot{ RoomID: live.room.ID, Phase: live.room.Phase, PlaybackState: live.room.PlaybackState, SelectionMode: live.room.SelectionMode, SelectionRevision: live.room.SelectionRevision, SelectedContentID: live.room.SelectedContentID, SelectedFileID: live.room.SelectedFileID, SelectedLibraryID: live.room.SelectedLibraryID, Code: live.room.Code, GuestControlPolicy: live.room.GuestControlPolicy, IsPaused: live.room.IsPaused, AnchorPositionSeconds: s.expectedPositionLocked(live), AnchorUpdatedAt: live.room.AnchorUpdatedAt.UTC().Format(time.RFC3339), Generation: live.room.Generation, MemberCount: s.connectedMemberCountLocked(live), HostConnected: s.hostConnectedLocked(live), SelfRole: roleFor(live.room, userID, profileID), SelfCanControlTransport: canControl, SelfCanManageRoom: isHost, SelfIgnoreWait: func() bool { if member == nil { return false } return member.ignoreWait }(), AttachedSessionID: func() string { if member == nil { return "" } return member.sessionID }(), InvitePath: invitePath, } } func (s *Service) prepareSnapshotDispatchesLocked(live *liveRoom) []snapshotDispatch { dispatches := make([]snapshotDispatch, 0, len(live.members)) for _, member := range live.members { if member == nil || member.connection == nil { continue } dispatches = append(dispatches, snapshotDispatch{ conn: member.connection, payload: map[string]any{ "type": "snapshot", "room": s.buildSnapshotLocked(live, member.userID, member.profileID), }, }) } return dispatches } func (s *Service) prepareRoomClosedDispatchesLocked(live *liveRoom) []snapshotDispatch { dispatches := make([]snapshotDispatch, 0, len(live.members)) for _, member := range live.members { if member == nil || member.connection == nil { continue } dispatches = append(dispatches, snapshotDispatch{ conn: member.connection, payload: map[string]any{ "type": "room_closed", "reason": "host_left", }, }) } return dispatches } func (s *Service) runDispatches(dispatches []snapshotDispatch) { for _, dispatch := range dispatches { if dispatch.conn == nil { continue } _ = dispatch.conn.WriteJSON(dispatch.payload) } } func (s *Service) connectedMemberCountLocked(live *liveRoom) int { count := 0 for _, member := range live.members { if member != nil && member.connection != nil { count++ } } return count } func (s *Service) hostConnectedLocked(live *liveRoom) bool { member := live.members[buildMemberKey(live.room.HostUserID, live.room.HostProfileID)] return member != nil && member.connection != nil } func (s *Service) sessionIDsLocked(live *liveRoom) []string { var sessionIDs []string for _, member := range live.members { if member == nil || member.sessionID == "" { continue } sessionIDs = append(sessionIDs, member.sessionID) } return sessionIDs } func (s *Service) hasHostAttachedSessionLocked(live *liveRoom) bool { member := live.members[buildMemberKey(live.room.HostUserID, live.room.HostProfileID)] return member != nil && member.sessionID != "" } func withoutSessionID(sessionIDs []string, excluded string) []string { if excluded == "" || len(sessionIDs) == 0 { return sessionIDs } filtered := make([]string, 0, len(sessionIDs)) for _, sessionID := range sessionIDs { if sessionID == excluded { continue } filtered = append(filtered, sessionID) } return filtered } func (s *Service) expectedPositionLocked(live *liveRoom) float64 { return expectedPosition(live.room, s.now()) } func (s *Service) resetMemberReadinessLocked(live *liveRoom, markBuffering bool) { for _, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" { continue } member.isReady = false if markBuffering { member.isBuffering = true } } } func (s *Service) activeParticipantCountLocked(live *liveRoom) int { count := 0 for _, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" || member.ignoreWait { continue } count++ } return count } func (s *Service) allParticipantsReadyLocked(live *liveRoom) bool { participants := 0 for _, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" || member.ignoreWait { continue } participants++ if !member.isReady { return false } } return participants > 0 } func (s *Service) highestPingLocked(live *liveRoom) time.Duration { highest := defaultTransportLead for _, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" { continue } if member.lastPingMS <= 0 { continue } delay := time.Duration(member.lastPingMS*2) * time.Millisecond if delay > highest { highest = delay } } if highest < minTransportLead { return minTransportLead } return highest } func (s *Service) targetedCommandDispatchesLocked( live *liveRoom, sessionID string, command TransportCommand, ) []commandDispatch { dispatches := make([]commandDispatch, 0, 1) for memberKey, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" || member.sessionID != sessionID { continue } payload := command payload.SessionID = member.sessionID dispatches = append(dispatches, commandDispatch{ conn: member.connection, memberKey: memberKey, payload: map[string]any{ "type": "transport_command", "command": payload, }, }) } return dispatches } func (s *Service) transportCommandDispatchesLocked( live *liveRoom, action TransportAction, positionSeconds float64, executeAt time.Time, ) []commandDispatch { dispatches := make([]commandDispatch, 0, len(live.members)) for memberKey, member := range live.members { if member == nil || member.connection == nil || member.sessionID == "" { continue } command := TransportCommand{ CommandID: uuid.NewString(), SessionID: member.sessionID, SelectionRevision: live.room.SelectionRevision, Action: action, PositionSeconds: math.Max(0, positionSeconds), ExecuteAt: executeAt.UTC().Format(time.RFC3339Nano), IssuedAt: s.now().UTC().Format(time.RFC3339Nano), PlaybackState: live.room.PlaybackState, } dispatches = append(dispatches, commandDispatch{ conn: member.connection, memberKey: memberKey, payload: map[string]any{ "type": "transport_command", "command": command, }, }) } return dispatches } func (s *Service) runCommandDispatches(dispatches []commandDispatch) { for _, dispatch := range dispatches { if dispatch.conn == nil { continue } _ = dispatch.conn.WriteJSON(dispatch.payload) } } func (s *Service) enterWaitingLocked(live *liveRoom, positionSeconds float64, resumeOnReady bool) ([]commandDispatch, bool) { if live.room.Phase != RoomPhasePlaying { return nil, false } live.room.AnchorPositionSeconds = math.Max(0, positionSeconds) live.room.IsPaused = true live.room.PlaybackState = RoomPlaybackStateWaiting live.room.ResumeOnReady = resumeOnReady live.room.AnchorUpdatedAt = s.now() s.resetMemberReadinessLocked(live, false) if s.activeParticipantCountLocked(live) == 0 { return nil, false } executeAt := s.now().Add(s.highestPingLocked(live)) return s.transportCommandDispatchesLocked( live, TransportActionPause, live.room.AnchorPositionSeconds, executeAt, ), true } func (s *Service) syncMemberToRoomLocked(live *liveRoom, sessionID string) []commandDispatch { if sessionID == "" { return nil } position := expectedPosition(live.room, s.now()) action := TransportActionPause if live.room.PlaybackState == RoomPlaybackStatePlaying { action = TransportActionPlay } return s.targetedCommandDispatchesLocked(live, sessionID, TransportCommand{ CommandID: uuid.NewString(), SelectionRevision: live.room.SelectionRevision, Action: action, PositionSeconds: math.Max(0, position), ExecuteAt: s.now().Add(s.highestPingLocked(live)).UTC().Format(time.RFC3339Nano), IssuedAt: s.now().UTC().Format(time.RFC3339Nano), PlaybackState: live.room.PlaybackState, }) } func expectedPosition(room Room, now time.Time) float64 { position := math.Max(0, room.AnchorPositionSeconds) if room.IsPaused { return position } elapsed := now.UTC().Sub(room.AnchorUpdatedAt.UTC()).Seconds() if elapsed <= 0 { return position } return position + elapsed } func buildMemberKey(userID int, profileID string) string { return fmt.Sprintf("%d:%s", userID, profileID) } func roleFor(room Room, userID int, profileID string) MemberRole { if room.HostUserID == userID && room.HostProfileID == profileID { return MemberRoleHost } return MemberRoleGuest } // --- Suggestion and Voting methods --- func (s *Service) CreateSuggestion( ctx context.Context, roomID string, userID int, profileID string, input CreateSuggestionInput, ) ([]Suggestion, error) { if s == nil || s.suggestions == nil { return nil, fmt.Errorf("watch together suggestions unavailable") } if input.ContentType != "movie" && input.ContentType != "episode" { return nil, ErrInvalidSelection } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return nil, err } s.mu.Lock() if live.room.Phase == RoomPhaseEnded { s.mu.Unlock() return nil, ErrRoomClosed } s.mu.Unlock() suggestion := Suggestion{ ID: uuid.NewString(), RoomID: roomID, SuggesterUserID: userID, SuggesterProfileID: profileID, ContentID: input.ContentID, ContentType: input.ContentType, Title: input.Title, Subtitle: input.Subtitle, PosterURL: input.PosterURL, Note: input.Note, VoteCount: 0, CreatedAt: s.now(), } if _, err := s.suggestions.CreateSuggestion(ctx, suggestion); err != nil { return nil, err } suggestions, err := s.suggestions.ListSuggestions(ctx, roomID, profileID) if err != nil { return nil, err } s.mu.Lock() live = s.rooms[roomID] if live != nil { dispatches := s.prepareSuggestionDispatchesLocked(live, suggestions) s.mu.Unlock() s.runDispatches(dispatches) } else { s.mu.Unlock() } return suggestions, nil } func (s *Service) ListSuggestions( ctx context.Context, roomID string, profileID string, ) ([]Suggestion, error) { if s == nil || s.suggestions == nil { return nil, fmt.Errorf("watch together suggestions unavailable") } return s.suggestions.ListSuggestions(ctx, roomID, profileID) } func (s *Service) DeleteSuggestion( ctx context.Context, roomID string, suggestionID string, userID int, profileID string, ) ([]Suggestion, error) { if s == nil || s.suggestions == nil { return nil, fmt.Errorf("watch together suggestions unavailable") } existing, err := s.suggestions.GetSuggestion(ctx, suggestionID) if err != nil { return nil, err } if existing.RoomID != roomID { return nil, ErrSuggestionNotFound } // Host can delete any; suggester can delete own _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return nil, err } s.mu.Lock() isHost := live.room.HostUserID == userID && live.room.HostProfileID == profileID s.mu.Unlock() isSuggester := existing.SuggesterUserID == userID && existing.SuggesterProfileID == profileID if !isHost && !isSuggester { return nil, ErrRoomForbidden } if err := s.suggestions.DeleteSuggestion(ctx, suggestionID); err != nil { return nil, err } suggestions, err := s.suggestions.ListSuggestions(ctx, roomID, profileID) if err != nil { return nil, err } s.mu.Lock() live = s.rooms[roomID] if live != nil { dispatches := s.prepareSuggestionDispatchesLocked(live, suggestions) s.mu.Unlock() s.runDispatches(dispatches) } else { s.mu.Unlock() } return suggestions, nil } func (s *Service) Vote( ctx context.Context, roomID string, suggestionID string, userID int, profileID string, ) ([]Suggestion, error) { if s == nil || s.suggestions == nil { return nil, fmt.Errorf("watch together suggestions unavailable") } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return nil, err } // Verify suggestion belongs to this room existing, err := s.suggestions.GetSuggestion(ctx, suggestionID) if err != nil { return nil, err } if existing.RoomID != roomID { return nil, ErrSuggestionNotFound } if err := s.suggestions.AddVote(ctx, suggestionID, profileID); err != nil { return nil, err } suggestions, err := s.suggestions.ListSuggestions(ctx, roomID, profileID) if err != nil { return nil, err } s.mu.Lock() live = s.rooms[roomID] if live != nil { dispatches := s.prepareSuggestionDispatchesLocked(live, suggestions) s.mu.Unlock() s.runDispatches(dispatches) } else { s.mu.Unlock() } return suggestions, nil } func (s *Service) Unvote( ctx context.Context, roomID string, suggestionID string, userID int, profileID string, ) ([]Suggestion, error) { if s == nil || s.suggestions == nil { return nil, fmt.Errorf("watch together suggestions unavailable") } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return nil, err } // Verify suggestion belongs to this room existing, err := s.suggestions.GetSuggestion(ctx, suggestionID) if err != nil { return nil, err } if existing.RoomID != roomID { return nil, ErrSuggestionNotFound } if err := s.suggestions.RemoveVote(ctx, suggestionID, profileID); err != nil { return nil, err } suggestions, err := s.suggestions.ListSuggestions(ctx, roomID, profileID) if err != nil { return nil, err } s.mu.Lock() live = s.rooms[roomID] if live != nil { dispatches := s.prepareSuggestionDispatchesLocked(live, suggestions) s.mu.Unlock() s.runDispatches(dispatches) } else { s.mu.Unlock() } return suggestions, nil } func (s *Service) PromoteSuggestion( ctx context.Context, roomID string, suggestionID string, userID int, profileID string, ) (Snapshot, error) { if s == nil || s.suggestions == nil { return Snapshot{}, fmt.Errorf("watch together suggestions unavailable") } _, live, err := s.getOrLoadLiveRoom(ctx, roomID) if err != nil { return Snapshot{}, err } s.mu.Lock() if live.room.HostUserID != userID || live.room.HostProfileID != profileID { s.mu.Unlock() return Snapshot{}, ErrRoomForbidden } s.mu.Unlock() suggestion, err := s.suggestions.GetSuggestion(ctx, suggestionID) if err != nil { return Snapshot{}, err } if suggestion.RoomID != roomID { return Snapshot{}, ErrSuggestionNotFound } return s.SelectItem(ctx, roomID, userID, profileID, SelectItemInput{ ContentID: suggestion.ContentID, }) } func (s *Service) prepareSuggestionDispatchesLocked(live *liveRoom, suggestions []Suggestion) []snapshotDispatch { // Strip voted_by_me from broadcast since it is relative to the requester. // Clients merge vote state from their local knowledge on receipt. broadcast := make([]Suggestion, len(suggestions)) copy(broadcast, suggestions) for i := range broadcast { broadcast[i].VotedByMe = false } dispatches := make([]snapshotDispatch, 0, len(live.members)) for _, member := range live.members { if member == nil || member.connection == nil { continue } dispatches = append(dispatches, snapshotDispatch{ conn: member.connection, payload: map[string]any{ "type": "suggestions_update", "suggestions": broadcast, }, }) } return dispatches } const roomTokenAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" func randomToken(length int) string { if length <= 0 { return "" } buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return uuid.NewString() } for i := range buf { buf[i] = roomTokenAlphabet[int(buf[i])%len(roomTokenAlphabet)] } return string(buf) }