2013 lines
53 KiB
Go
2013 lines
53 KiB
Go
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)
|
|
}
|