647 lines
18 KiB
Go
647 lines
18 KiB
Go
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
|
|
}
|