Files
silo-server/internal/watchtogether/service.go
T

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)
}