package nakama import ( "cmp" "context" "encoding/json" "errors" "fmt" "seanime/internal/database/models" debrid_client "seanime/internal/debrid/client" "seanime/internal/events" "seanime/internal/library/playbackmanager" "seanime/internal/platforms/platform" "seanime/internal/torrentstream" "seanime/internal/util" "seanime/internal/util/result" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/imroc/req/v3" "github.com/rs/zerolog" ) type Manager struct { serverHost string serverPort int username string logger *zerolog.Logger settings *models.NakamaSettings wsEventManager events.WSEventManagerInterface platform platform.Platform playbackManager *playbackmanager.PlaybackManager torrentstreamRepository *torrentstream.Repository debridClientRepository *debrid_client.Repository peerId string // Host connections (when acting as host) peerConnections *result.Map[string, *PeerConnection] // Host connection (when connecting to a host) hostConnection *HostConnection hostConnectionCtx context.Context hostConnectionCancel context.CancelFunc hostMu sync.RWMutex reconnecting bool // Flag to prevent multiple concurrent reconnection attempts // Connection management cancel context.CancelFunc ctx context.Context // Message handlers messageHandlers map[MessageType]func(*Message, string) error handlerMu sync.RWMutex // Cleanup functions cleanups []func() reqClient *req.Client watchPartyManager *WatchPartyManager previousPath string // latest file streamed by the peer - real path on the host } type NewManagerOptions struct { Logger *zerolog.Logger WSEventManager events.WSEventManagerInterface PlaybackManager *playbackmanager.PlaybackManager TorrentstreamRepository *torrentstream.Repository DebridClientRepository *debrid_client.Repository Platform platform.Platform ServerHost string ServerPort int } type ConnectionType string const ( ConnectionTypeHost ConnectionType = "host" ConnectionTypePeer ConnectionType = "peer" ) // MessageType represents the type of message being sent type MessageType string const ( MessageTypeAuth MessageType = "auth" MessageTypeAuthReply MessageType = "auth_reply" MessageTypePing MessageType = "ping" MessageTypePong MessageType = "pong" MessageTypeError MessageType = "error" MessageTypeCustom MessageType = "custom" ) // Message represents a message sent between Nakama instances type Message struct { Type MessageType `json:"type"` Payload interface{} `json:"payload"` RequestID string `json:"requestId,omitempty"` Timestamp time.Time `json:"timestamp"` } // PeerConnection represents a connection from a peer to this host type PeerConnection struct { ID string // Internal connection ID (websocket) PeerId string // UUID generated by the peer (primary identifier) Username string // Display name (kept for UI purposes) Conn *websocket.Conn ConnectionType ConnectionType Authenticated bool LastPing time.Time mu sync.RWMutex } // HostConnection represents this instance's connection to a host type HostConnection struct { URL string PeerId string // UUID generated by this peer instance Username string Conn *websocket.Conn Authenticated bool LastPing time.Time reconnectTimer *time.Timer mu sync.RWMutex } // NakamaEvent represents events sent to the client type NakamaEvent struct { Type string `json:"type"` Payload interface{} `json:"payload"` } // AuthPayload represents authentication data type AuthPayload struct { Password string `json:"password"` PeerId string `json:"peerId"` // UUID generated by the peer } // AuthReplyPayload represents authentication response type AuthReplyPayload struct { Success bool `json:"success"` Message string `json:"message"` Username string `json:"username"` PeerId string `json:"peerId"` // Echo back the peer's UUID } // ErrorPayload represents error messages type ErrorPayload struct { Message string `json:"message"` Code string `json:"code,omitempty"` } // HostConnectionStatus represents the status of the host connection type HostConnectionStatus struct { Connected bool `json:"connected"` Authenticated bool `json:"authenticated"` URL string `json:"url"` LastPing time.Time `json:"lastPing"` PeerId string `json:"peerId"` Username string `json:"username"` } // NakamaStatus represents the overall status of Nakama connections type NakamaStatus struct { IsHost bool `json:"isHost"` ConnectedPeers []string `json:"connectedPeers"` IsConnectedToHost bool `json:"isConnectedToHost"` HostConnectionStatus *HostConnectionStatus `json:"hostConnectionStatus"` CurrentWatchPartySession *WatchPartySession `json:"currentWatchPartySession"` } // MessageResponse represents a response to message sending requests type MessageResponse struct { Success bool `json:"success"` Message string `json:"message"` } type ClientEvent struct { Type string `json:"type"` Payload interface{} `json:"payload"` } func NewManager(opts *NewManagerOptions) *Manager { ctx, cancel := context.WithCancel(context.Background()) m := &Manager{ username: "", logger: opts.Logger, wsEventManager: opts.WSEventManager, playbackManager: opts.PlaybackManager, peerConnections: result.NewResultMap[string, *PeerConnection](), platform: opts.Platform, ctx: ctx, cancel: cancel, messageHandlers: make(map[MessageType]func(*Message, string) error), cleanups: make([]func(), 0), reqClient: req.C(), serverHost: opts.ServerHost, serverPort: opts.ServerPort, settings: &models.NakamaSettings{}, torrentstreamRepository: opts.TorrentstreamRepository, debridClientRepository: opts.DebridClientRepository, previousPath: "", } m.watchPartyManager = NewWatchPartyManager(m) // Register default message handlers m.registerDefaultHandlers() eventListener := m.wsEventManager.SubscribeToClientEvents("nakama") go func() { for event := range eventListener.Channel { if event.Type == events.NakamaStatusRequested { currSession, _ := m.GetWatchPartyManager().GetCurrentSession() status := &NakamaStatus{ IsHost: m.IsHost(), ConnectedPeers: m.GetConnectedPeers(), IsConnectedToHost: m.IsConnectedToHost(), HostConnectionStatus: m.GetHostConnectionStatus(), CurrentWatchPartySession: currSession, } m.wsEventManager.SendEvent(events.NakamaStatus, status) } if event.Type == events.NakamaWatchPartyEnableRelayMode { var payload WatchPartyEnableRelayModePayload marshaledPayload, err := json.Marshal(event.Payload) if err != nil { m.logger.Error().Err(err).Msg("nakama: Failed to marshal watch party enable relay mode payload") continue } err = json.Unmarshal(marshaledPayload, &payload) if err != nil { m.logger.Error().Err(err).Msg("nakama: Failed to unmarshal watch party enable relay mode payload") continue } m.GetWatchPartyManager().EnableRelayMode(payload.PeerId) } } }() return m } func (m *Manager) SetSettings(settings *models.NakamaSettings) { var previousSettings *models.NakamaSettings if m.settings != nil { previousSettings = &[]models.NakamaSettings{*m.settings}[0] } // If the host password has changed, stop host service // This will cause a restart of the host service disconnectAsHost := false if m.settings != nil && m.settings.HostPassword != settings.HostPassword { disconnectAsHost = true m.stopHostServices() } m.settings = settings m.username = cmp.Or(settings.Username, "Peer_"+util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789")) m.logger.Debug().Bool("isHost", settings.IsHost).Str("username", m.username).Str("remoteURL", settings.RemoteServerURL).Msg("nakama: Settings updated") if previousSettings == nil || previousSettings.IsHost != settings.IsHost || previousSettings.Enabled != settings.Enabled || disconnectAsHost { // Determine if we should stop host services shouldStopHost := m.IsHost() && (!settings.Enabled || // Nakama disabled !settings.IsHost || // Switching to peer mode disconnectAsHost) // Password changed (requires restart) // Determine if we should start host services shouldStartHost := settings.IsHost && settings.Enabled // Always stop first if needed, then start if shouldStopHost { m.stopHostServices() } if shouldStartHost { m.startHostServices() } } if previousSettings == nil || previousSettings.RemoteServerURL != settings.RemoteServerURL || previousSettings.RemoteServerPassword != settings.RemoteServerPassword || previousSettings.Enabled != settings.Enabled { // Determine if we should disconnect from current host shouldDisconnect := m.IsConnectedToHost() && (!settings.Enabled || // Nakama disabled settings.IsHost || // Switching to host mode settings.RemoteServerURL == "" || // No remote URL settings.RemoteServerPassword == "" || // No password (previousSettings != nil && previousSettings.RemoteServerURL != settings.RemoteServerURL) || // URL changed (previousSettings != nil && previousSettings.RemoteServerPassword != settings.RemoteServerPassword)) // Password changed // Determine if we should connect to a host shouldConnect := !settings.IsHost && settings.Enabled && settings.RemoteServerURL != "" && settings.RemoteServerPassword != "" // Always disconnect first if needed, then connect if shouldDisconnect { m.disconnectFromHost() } if shouldConnect { m.connectToHost() } } // if previousSettings == nil || previousSettings.Username != settings.Username { // m.SendMessage(MessageTypeCustom, map[string]interface{}{ // "type": "nakama_username_changed", // "username": settings.Username, // }) // } } func (m *Manager) GetHostBaseServerURL() string { url := m.settings.RemoteServerURL if strings.HasSuffix(url, "/") { url = strings.TrimSuffix(url, "/") } return url } func (m *Manager) IsHost() bool { return m.settings.IsHost } func (m *Manager) GetHostConnection() (*HostConnection, bool) { m.hostMu.RLock() defer m.hostMu.RUnlock() return m.hostConnection, m.hostConnection != nil } // GetWatchPartyManager returns the watch party manager func (m *Manager) GetWatchPartyManager() *WatchPartyManager { return m.watchPartyManager } // Cleanup stops all connections and services func (m *Manager) Cleanup() { m.logger.Debug().Msg("nakama: Cleaning up") if m.cancel != nil { m.cancel() } // Cancel any ongoing host connection attempts m.hostMu.Lock() if m.hostConnectionCancel != nil { m.hostConnectionCancel() m.hostConnectionCancel = nil } m.hostMu.Unlock() // Cleanup host connections m.peerConnections.Range(func(id string, conn *PeerConnection) bool { conn.Close() return true }) m.peerConnections.Clear() // Cleanup client connection m.hostMu.Lock() if m.hostConnection != nil { m.hostConnection.Close() m.hostConnection = nil } m.hostMu.Unlock() // Run cleanup functions for _, cleanup := range m.cleanups { cleanup() } } // RegisterMessageHandler registers a custom message handler func (m *Manager) RegisterMessageHandler(msgType MessageType, handler func(*Message, string) error) { m.handlerMu.Lock() defer m.handlerMu.Unlock() m.messageHandlers[msgType] = handler } // SendMessage sends a message to all connected peers (when acting as host) func (m *Manager) SendMessage(msgType MessageType, payload interface{}) error { if !m.settings.IsHost { return errors.New("not acting as host") } message := &Message{ Type: msgType, Payload: payload, Timestamp: time.Now(), } var lastError error m.peerConnections.Range(func(id string, conn *PeerConnection) bool { if err := conn.SendMessage(message); err != nil { m.logger.Error().Err(err).Str("peerId", conn.PeerId).Msg("nakama: Failed to send message to peer") lastError = err } return true }) return lastError } // SendMessageToPeer sends a message to a specific peer by their PeerID func (m *Manager) SendMessageToPeer(peerID string, msgType MessageType, payload interface{}) error { if !m.settings.IsHost { return errors.New("only hosts can send messages to peers") } // Find peer by PeerID var targetConn *PeerConnection m.peerConnections.Range(func(id string, conn *PeerConnection) bool { if conn.PeerId == peerID { targetConn = conn return false // Stop iteration } return true }) if targetConn == nil { return errors.New("peer not found: " + peerID) } message := &Message{ Type: msgType, Payload: payload, Timestamp: time.Now(), } return targetConn.SendMessage(message) } // SendMessageToHost sends a message to the host (when acting as peer) func (m *Manager) SendMessageToHost(msgType MessageType, payload interface{}) error { m.hostMu.RLock() defer m.hostMu.RUnlock() if m.hostConnection == nil || !m.hostConnection.Authenticated { return errors.New("not connected to host") } message := &Message{ Type: msgType, Payload: payload, Timestamp: time.Now(), } return m.hostConnection.SendMessage(message) } // GetConnectedPeers returns a list of connected peer IDs func (m *Manager) GetConnectedPeers() []string { if !m.settings.IsHost { return []string{} } peers := make([]string, 0) m.peerConnections.Range(func(id string, conn *PeerConnection) bool { if conn.Authenticated { // Use PeerID as the primary identifier peerDisplayName := conn.Username if peerDisplayName == "" { peerDisplayName = "Unknown" } // Format: "Username (PeerID_short)" // peers = append(peers, fmt.Sprintf("%s (%s)", peerDisplayName, conn.PeerId[:8])) peers = append(peers, fmt.Sprintf("%s", peerDisplayName)) } return true }) return peers } // IsConnectedToHost returns whether this instance is connected to a host func (m *Manager) IsConnectedToHost() bool { m.hostMu.RLock() defer m.hostMu.RUnlock() return m.hostConnection != nil && m.hostConnection.Authenticated } // GetHostConnectionStatus returns the status of the host connection func (m *Manager) GetHostConnectionStatus() *HostConnectionStatus { m.hostMu.RLock() defer m.hostMu.RUnlock() if m.hostConnection == nil { return nil } return &HostConnectionStatus{ Connected: m.hostConnection != nil, Authenticated: m.hostConnection != nil && m.hostConnection.Authenticated, URL: m.hostConnection.URL, LastPing: m.hostConnection.LastPing, PeerId: m.hostConnection.PeerId, Username: m.hostConnection.Username, } } func (pc *PeerConnection) SendMessage(message *Message) error { pc.mu.Lock() defer pc.mu.Unlock() return pc.Conn.WriteJSON(message) } func (pc *PeerConnection) Close() { pc.mu.Lock() defer pc.mu.Unlock() _ = pc.Conn.Close() } func (hc *HostConnection) SendMessage(message *Message) error { hc.mu.Lock() defer hc.mu.Unlock() return hc.Conn.WriteJSON(message) } func (hc *HostConnection) Close() { hc.mu.Lock() defer hc.mu.Unlock() if hc.reconnectTimer != nil { hc.reconnectTimer.Stop() } _ = hc.Conn.Close() } // Helper function to generate connection IDs func generateConnectionID() string { return fmt.Sprintf("conn_%d", time.Now().UnixNano()) } // ReconnectToHost attempts to reconnect to the host func (m *Manager) ReconnectToHost() error { if m.settings == nil || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" { return errors.New("no host connection configured") } // Check if already reconnecting m.hostMu.Lock() if m.reconnecting { m.hostMu.Unlock() return errors.New("reconnection already in progress") } m.hostMu.Unlock() m.logger.Info().Msg("nakama: Manual reconnection to host requested") // Disconnect current connection if exists m.disconnectFromHost() // Wait a moment before reconnecting time.Sleep(1 * time.Second) // Reconnect m.connectToHost() return nil } // RemoveStaleConnections removes connections that haven't responded to ping in a while func (m *Manager) RemoveStaleConnections() { if !m.settings.IsHost { return } staleThreshold := 90 * time.Second // Consider connections stale after 90 seconds of no ping now := time.Now() var staleConnections []string m.peerConnections.Range(func(id string, conn *PeerConnection) bool { conn.mu.RLock() lastPing := conn.LastPing authenticated := conn.Authenticated conn.mu.RUnlock() // Only check authenticated connections if !authenticated { return true } // If LastPing is zero, use connection time as reference if lastPing.IsZero() { lastPing = now.Add(-staleThreshold - time.Minute) } if now.Sub(lastPing) > staleThreshold { staleConnections = append(staleConnections, id) } return true }) // Remove stale connections for _, id := range staleConnections { if conn, exists := m.peerConnections.Get(id); exists { // Double-check to avoid race conditions conn.mu.RLock() lastPing := conn.LastPing if lastPing.IsZero() { lastPing = now.Add(-staleThreshold - time.Minute) } isStale := now.Sub(lastPing) > staleThreshold conn.mu.RUnlock() if isStale { m.logger.Info().Str("peerId", conn.PeerId).Str("internalConnID", id).Msg("nakama: Removing stale peer connection") // Remove from map first to prevent re-addition m.peerConnections.Delete(id) // Remove peer from watch party if they were participating m.watchPartyManager.HandlePeerDisconnected(conn.PeerId) // Then close the connection (this will trigger the defer cleanup in handlePeerConnection) conn.Close() // Send event about peer disconnection m.wsEventManager.SendEvent(events.NakamaPeerDisconnected, map[string]interface{}{ "peerId": conn.PeerId, "reason": "stale_connection", }) } } } if len(staleConnections) > 0 { m.logger.Info().Int("count", len(staleConnections)).Msg("nakama: Removed stale peer connections") } } // FindPeerByPeerID finds a peer connection by their PeerID func (m *Manager) FindPeerByPeerID(peerID string) (*PeerConnection, bool) { var found *PeerConnection m.peerConnections.Range(func(id string, conn *PeerConnection) bool { if conn.PeerId == peerID { found = conn return false // Stop iteration } return true }) return found, found != nil }