package handlers import ( "encoding/base64" "encoding/json" "errors" "io" "net/http" "seanime/internal/database/db_bridge" "seanime/internal/library/anime" "seanime/internal/nakama" "seanime/internal/util" "strconv" "strings" "time" "github.com/labstack/echo/v4" "github.com/samber/lo" ) // HandleNakamaWebSocket handles WebSocket connections for Nakama peers // // @summary handles WebSocket connections for Nakama peers. // @desc This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host. // @route /api/v1/nakama/ws [GET] func (h *Handler) HandleNakamaWebSocket(c echo.Context) error { // Use the standard library HTTP ResponseWriter and Request w := c.Response().Writer r := c.Request() // Let the Nakama manager handle the WebSocket connection h.App.NakamaManager.HandlePeerConnection(w, r) return nil } // HandleSendNakamaMessage // // @summary sends a custom message through Nakama. // @desc This allows sending custom messages to connected peers or the host. // @route /api/v1/nakama/message [POST] // @returns nakama.MessageResponse func (h *Handler) HandleSendNakamaMessage(c echo.Context) error { type body struct { MessageType string `json:"messageType"` Payload interface{} `json:"payload"` PeerID string `json:"peerId,omitempty"` // If specified, send to specific peer (host only) } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } var err error if b.PeerID != "" && h.App.Settings.GetNakama().IsHost { // Send to specific peer err = h.App.NakamaManager.SendMessageToPeer(b.PeerID, nakama.MessageType(b.MessageType), b.Payload) } else if h.App.Settings.GetNakama().IsHost { // Send to all peers err = h.App.NakamaManager.SendMessage(nakama.MessageType(b.MessageType), b.Payload) } else { // Send to host err = h.App.NakamaManager.SendMessageToHost(nakama.MessageType(b.MessageType), b.Payload) } if err != nil { return h.RespondWithError(c, err) } response := &nakama.MessageResponse{ Success: true, Message: "Message sent successfully", } return h.RespondWithData(c, response) } // HandleGetNakamaAnimeLibrary // // @summary shares the local anime collection with Nakama clients. // @desc This creates a new LibraryCollection struct and returns it. // @desc This is used to share the local anime collection with Nakama clients. // @route /api/v1/nakama/host/anime/library/collection [GET] // @returns nakama.NakamaAnimeLibrary func (h *Handler) HandleGetNakamaAnimeLibrary(c echo.Context) error { if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { return h.RespondWithError(c, errors.New("host is not sharing its anime library")) } animeCollection, err := h.App.GetAnimeCollection(false) if err != nil { return h.RespondWithError(c, err) } if animeCollection == nil { return h.RespondWithData(c, &anime.LibraryCollection{}) } lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) if err != nil { return h.RespondWithError(c, err) } unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds unsharedAnimeIdsMap := make(map[int]struct{}) unsharedAnimeIdsMap[0] = struct{}{} // Do not share unmatched files for _, id := range unsharedAnimeIds { unsharedAnimeIdsMap[id] = struct{}{} } lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { _, ok := unsharedAnimeIdsMap[lf.MediaId] return !ok }) libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{ AnimeCollection: animeCollection, Platform: h.App.AnilistPlatform, LocalFiles: lfs, MetadataProvider: h.App.MetadataProvider, }) if err != nil { return h.RespondWithError(c, err) } // Hydrate total library size if libraryCollection != nil && libraryCollection.Stats != nil { libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize) } return h.RespondWithData(c, &nakama.NakamaAnimeLibrary{ LocalFiles: lfs, AnimeCollection: animeCollection, }) } // HandleGetNakamaAnimeLibraryCollection // // @summary shares the local anime collection with Nakama clients. // @desc This creates a new LibraryCollection struct and returns it. // @desc This is used to share the local anime collection with Nakama clients. // @route /api/v1/nakama/host/anime/library/collection [GET] // @returns anime.LibraryCollection func (h *Handler) HandleGetNakamaAnimeLibraryCollection(c echo.Context) error { if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { return h.RespondWithError(c, errors.New("host is not sharing its anime library")) } animeCollection, err := h.App.GetAnimeCollection(false) if err != nil { return h.RespondWithError(c, err) } if animeCollection == nil { return h.RespondWithData(c, &anime.LibraryCollection{}) } lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) if err != nil { return h.RespondWithError(c, err) } unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds unsharedAnimeIdsMap := make(map[int]struct{}) unsharedAnimeIdsMap[0] = struct{}{} for _, id := range unsharedAnimeIds { unsharedAnimeIdsMap[id] = struct{}{} } lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { _, ok := unsharedAnimeIdsMap[lf.MediaId] return !ok }) libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{ AnimeCollection: animeCollection, Platform: h.App.AnilistPlatform, LocalFiles: lfs, MetadataProvider: h.App.MetadataProvider, }) if err != nil { return h.RespondWithError(c, err) } // Hydrate total library size if libraryCollection != nil && libraryCollection.Stats != nil { libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize) } return h.RespondWithData(c, libraryCollection) } // HandleGetNakamaAnimeLibraryFiles // // @summary return the local files for the given AniList anime media id. // @desc This is used by the anime media entry pages to get all the data about the anime. // @route /api/v1/nakama/host/anime/library/files/{id} [POST] // @param id - int - true - "AniList anime media ID" // @returns []anime.LocalFile func (h *Handler) HandleGetNakamaAnimeLibraryFiles(c echo.Context) error { if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { return h.RespondWithError(c, errors.New("host is not sharing its anime library")) } mId, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } // Get all the local files lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) if err != nil { return h.RespondWithError(c, err) } unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds unsharedAnimeIdsMap := make(map[int]struct{}) unsharedAnimeIdsMap[0] = struct{}{} for _, id := range unsharedAnimeIds { unsharedAnimeIdsMap[id] = struct{}{} } retLfs := lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { if _, ok := unsharedAnimeIdsMap[lf.MediaId]; ok { return false } return lf.MediaId == mId }) return h.RespondWithData(c, retLfs) } // HandleGetNakamaAnimeAllLibraryFiles // // @summary return all the local files for the host. // @desc This is used to share the local anime collection with Nakama clients. // @route /api/v1/nakama/host/anime/library/files [POST] // @returns []anime.LocalFile func (h *Handler) HandleGetNakamaAnimeAllLibraryFiles(c echo.Context) error { if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary { return h.RespondWithError(c, errors.New("host is not sharing its anime library")) } // Get all the local files lfs, _, err := db_bridge.GetLocalFiles(h.App.Database) if err != nil { return h.RespondWithError(c, err) } unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds unsharedAnimeIdsMap := make(map[int]struct{}) unsharedAnimeIdsMap[0] = struct{}{} for _, id := range unsharedAnimeIds { unsharedAnimeIdsMap[id] = struct{}{} } lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool { _, ok := unsharedAnimeIdsMap[lf.MediaId] return !ok }) return h.RespondWithData(c, lfs) } // HandleNakamaPlayVideo // // @summary plays the media from the host. // @route /api/v1/nakama/play [POST] // @returns bool func (h *Handler) HandleNakamaPlayVideo(c echo.Context) error { type body struct { Path string `json:"path"` MediaId int `json:"mediaId"` AniDBEpisode string `json:"anidbEpisode"` } b := new(body) if err := c.Bind(b); err != nil { return h.RespondWithError(c, err) } if !h.App.NakamaManager.IsConnectedToHost() { return h.RespondWithError(c, errors.New("not connected to host")) } media, err := h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId) if err != nil { return h.RespondWithError(c, err) } err = h.App.NakamaManager.PlayHostAnimeLibraryFile(b.Path, c.Request().Header.Get("User-Agent"), media, b.AniDBEpisode) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } // Note: This is not used anymore. Each peer will independently stream the torrent. // route /api/v1/nakama/host/torrentstream/stream // Allows peers to stream the currently playing torrent. func (h *Handler) HandleNakamaHostTorrentstreamServeStream(c echo.Context) error { h.App.TorrentstreamRepository.HTTPStreamHandler().ServeHTTP(c.Response().Writer, c.Request()) return nil } var videoProxyClient = &http.Client{ Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, ForceAttemptHTTP2: false, // Fixes issues on Linux }, Timeout: 60 * time.Second, } // route /api/v1/nakama/host/debridstream/stream // Allows peers to stream the currently playing torrent. func (h *Handler) HandleNakamaHostDebridstreamServeStream(c echo.Context) error { streamUrl, ok := h.App.DebridClientRepository.GetStreamURL() if !ok { return echo.NewHTTPError(http.StatusNotFound, "no stream url") } // Proxy the stream to the peer // The debrid stream URL directly comes from the debrid service req, err := http.NewRequest(c.Request().Method, streamUrl, c.Request().Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") } // Copy original request headers to the proxied request for key, values := range c.Request().Header { for _, value := range values { req.Header.Add(key, value) } } resp, err := videoProxyClient.Do(req) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") } defer resp.Body.Close() // Copy response headers for key, values := range resp.Header { for _, value := range values { c.Response().Header().Add(key, value) } } // Set the status code c.Response().WriteHeader(resp.StatusCode) // Stream the response body _, err = io.Copy(c.Response().Writer, resp.Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body") } return nil } // route /api/v1/nakama/host/debridstream/url // Returns the debrid stream URL for direct access by peers to avoid host bandwidth usage func (h *Handler) HandleNakamaHostGetDebridstreamURL(c echo.Context) error { streamUrl, ok := h.App.DebridClientRepository.GetStreamURL() if !ok { return echo.NewHTTPError(http.StatusNotFound, "no stream url") } return h.RespondWithData(c, map[string]string{ "streamUrl": streamUrl, }) } // route /api/v1/nakama/host/anime/library/stream?path={base64_encoded_path} func (h *Handler) HandleNakamaHostAnimeLibraryServeStream(c echo.Context) error { filepath := c.QueryParam("path") decodedPath, err := base64.StdEncoding.DecodeString(filepath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid path") } h.App.Logger.Info().Msgf("nakama: Serving anime library file: %s", string(decodedPath)) // Make sure file is in library isInLibrary := false libraryPaths := h.App.Settings.GetLibrary().GetLibraryPaths() for _, libraryPath := range libraryPaths { if util.IsFileUnderDir(string(decodedPath), libraryPath) { isInLibrary = true break } } if !isInLibrary { return echo.NewHTTPError(http.StatusNotFound, "file not in library") } return c.File(string(decodedPath)) } // route /api/v1/nakama/stream // Proxies stream requests to the host. It inserts the Nakama password in the headers. // It checks if the password is valid. // For debrid streams, it redirects directly to the debrid service to avoid host bandwidth usage. func (h *Handler) HandleNakamaProxyStream(c echo.Context) error { streamType := c.QueryParam("type") // "file", "torrent", "debrid" if streamType == "" { return echo.NewHTTPError(http.StatusBadRequest, "type is required") } hostServerUrl := h.App.Settings.GetNakama().RemoteServerURL hostServerUrl = strings.TrimSuffix(hostServerUrl, "/") if streamType == "debrid" { // Get the debrid stream URL from the host urlEndpoint := hostServerUrl + "/api/v1/nakama/host/debridstream/url" req, err := http.NewRequest(http.MethodGet, urlEndpoint, nil) if err != nil { h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to create debrid URL request") return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") } // Add Nakama password for authentication req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) client := &http.Client{ Timeout: 30 * time.Second, } resp, err := client.Do(req) if err != nil { h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL") return echo.NewHTTPError(http.StatusBadGateway, "failed to get stream URL") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL") return echo.NewHTTPError(resp.StatusCode, "failed to get stream URL") } // Parse the response to get the stream URL type urlResponse struct { Data struct { StreamUrl string `json:"streamUrl"` } `json:"data"` } var urlResp urlResponse body, err := io.ReadAll(resp.Body) if err != nil { h.App.Logger.Error().Err(err).Msg("nakama: Failed to read debrid URL response") return echo.NewHTTPError(http.StatusInternalServerError, "failed to read response") } if err := json.Unmarshal(body, &urlResp); err != nil { h.App.Logger.Error().Err(err).Msg("nakama: Failed to parse debrid URL response") return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse response") } if urlResp.Data.StreamUrl == "" { h.App.Logger.Error().Msg("nakama: Empty debrid stream URL") return echo.NewHTTPError(http.StatusNotFound, "no stream URL available") } req, err = http.NewRequest(c.Request().Method, urlResp.Data.StreamUrl, c.Request().Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") } // Copy original request headers to the proxied request for key, values := range c.Request().Header { for _, value := range values { req.Header.Add(key, value) } } resp, err = videoProxyClient.Do(req) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") } defer resp.Body.Close() // Copy response headers for key, values := range resp.Header { for _, value := range values { c.Response().Header().Add(key, value) } } // Set the status code c.Response().WriteHeader(resp.StatusCode) // Stream the response body _, err = io.Copy(c.Response().Writer, resp.Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body") } return nil } requestUrl := "" switch streamType { case "file": // Path should be base64 encoded filepath := c.QueryParam("path") if filepath == "" { return echo.NewHTTPError(http.StatusBadRequest, "path is required") } requestUrl = hostServerUrl + "/api/v1/nakama/host/anime/library/stream?path=" + filepath case "torrent": requestUrl = hostServerUrl + "/api/v1/nakama/host/torrentstream/stream" default: return echo.NewHTTPError(http.StatusBadRequest, "invalid type") } client := &http.Client{ Transport: &http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 2, IdleConnTimeout: 30 * time.Second, DisableKeepAlives: true, // Disable keep-alive to prevent connection reuse issues ForceAttemptHTTP2: false, }, Timeout: 120 * time.Second, } if c.Request().Method == http.MethodHead { req, err := http.NewRequest(http.MethodHead, requestUrl, nil) if err != nil { h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create HEAD request") return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") } // Add Nakama password for authentication req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) // Add User-Agent from original request if userAgent := c.Request().Header.Get("User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } resp, err := client.Do(req) if err != nil { h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to proxy HEAD request") return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request") } defer resp.Body.Close() // Log authentication failures if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: Authentication failed - check password configuration") } // Copy response headers for key, values := range resp.Header { for _, value := range values { c.Response().Header().Add(key, value) } } return c.NoContent(resp.StatusCode) } // Create request with timeout context ctx := c.Request().Context() req, err := http.NewRequestWithContext(ctx, c.Request().Method, requestUrl, c.Request().Body) if err != nil { h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create request") return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request") } // Copy request headers but skip problematic ones for key, values := range c.Request().Header { // Skip headers that should not be forwarded or might cause errors if key == "Host" || key == "Content-Length" || key == "Connection" || key == "Transfer-Encoding" || key == "Accept-Encoding" || key == "Upgrade" || key == "Proxy-Connection" || strings.HasPrefix(key, "Sec-") { // Skip WebSocket and security headers continue } for _, value := range values { req.Header.Add(key, value) } } req.Header.Set("Accept", "*/*") // req.Header.Set("Accept-Encoding", "identity") // Disable compression to avoid issues // Add Nakama password for authentication req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword) h.App.Logger.Debug().Str("url", requestUrl).Str("method", c.Request().Method).Msg("nakama: Proxying request") // Add retry mechanism for intermittent network issues var resp *http.Response maxRetries := 3 for attempt := 0; attempt < maxRetries; attempt++ { resp, err = client.Do(req) if err == nil { break } if attempt < maxRetries-1 { h.App.Logger.Warn().Err(err).Int("attempt", attempt+1).Str("url", requestUrl).Msg("nakama: request failed, retrying") time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) // Exponential backoff continue } h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: failed to proxy request after retries") return echo.NewHTTPError(http.StatusBadGateway, "failed to proxy request after retries") } defer resp.Body.Close() // Log authentication failures with more detail if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: authentication failed - verify RemoteServerPassword matches host's HostPassword") } // Log and handle 406 Not Acceptable errors if resp.StatusCode == http.StatusNotAcceptable { h.App.Logger.Error().Int("status", resp.StatusCode).Str("url", requestUrl).Str("content-type", resp.Header.Get("Content-Type")).Msg("nakama: 406 Not Acceptable - content negotiation failed") } // Handle range request errors if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Str("range", c.Request().Header.Get("Range")).Msg("nakama: range request not satisfiable") } // Copy response headers for key, values := range resp.Header { for _, value := range values { c.Response().Header().Add(key, value) } } // Set the status code c.Response().WriteHeader(resp.StatusCode) // Stream the response body with better error handling bytesWritten, err := io.Copy(c.Response().Writer, resp.Body) if err != nil { // Check if it's a network-related error if strings.Contains(err.Error(), "connection") || strings.Contains(err.Error(), "broken pipe") || strings.Contains(err.Error(), "wsasend") || strings.Contains(err.Error(), "reset by peer") { h.App.Logger.Warn().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: network connection error during streaming") } else { h.App.Logger.Error().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: error streaming response body") } // Don't return error here as response has already started } else { h.App.Logger.Debug().Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: successfully streamed response") } return nil } // HandleNakamaReconnectToHost // // @summary reconnects to the Nakama host. // @desc This attempts to reconnect to the configured Nakama host if the connection was lost. // @route /api/v1/nakama/reconnect [POST] // @returns nakama.MessageResponse func (h *Handler) HandleNakamaReconnectToHost(c echo.Context) error { err := h.App.NakamaManager.ReconnectToHost() if err != nil { return h.RespondWithError(c, err) } response := &nakama.MessageResponse{ Success: true, Message: "Reconnection initiated", } return h.RespondWithData(c, response) } // HandleNakamaRemoveStaleConnections // // @summary removes stale peer connections. // @desc This removes peer connections that haven't responded to ping messages for a while. // @route /api/v1/nakama/cleanup [POST] // @returns nakama.MessageResponse func (h *Handler) HandleNakamaRemoveStaleConnections(c echo.Context) error { if !h.App.Settings.GetNakama().IsHost { return h.RespondWithError(c, errors.New("not acting as host")) } h.App.NakamaManager.RemoveStaleConnections() response := &nakama.MessageResponse{ Success: true, Message: "Stale connections cleaned up", } return h.RespondWithData(c, response) } // HandleNakamaCreateWatchParty // // @summary creates a new watch party session. // @desc This creates a new watch party that peers can join to watch content together in sync. // @route /api/v1/nakama/watch-party/create [POST] // @returns bool func (h *Handler) HandleNakamaCreateWatchParty(c echo.Context) error { type body struct { Settings *nakama.WatchPartySessionSettings `json:"settings"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } if !h.App.Settings.GetNakama().IsHost { return h.RespondWithError(c, errors.New("only hosts can create watch parties")) } // Set default settings if not provided if b.Settings == nil { b.Settings = &nakama.WatchPartySessionSettings{ SyncThreshold: 2.0, MaxBufferWaitTime: 10, } } _, err := h.App.NakamaManager.GetWatchPartyManager().CreateWatchParty(&nakama.CreateWatchOptions{ Settings: b.Settings, }) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } // HandleNakamaJoinWatchParty // // @summary joins an existing watch party. // @desc This allows a peer to join an active watch party session. // @route /api/v1/nakama/watch-party/join [POST] // @returns bool func (h *Handler) HandleNakamaJoinWatchParty(c echo.Context) error { if h.App.Settings.GetNakama().IsHost { return h.RespondWithError(c, errors.New("hosts cannot join watch parties")) } if !h.App.NakamaManager.IsConnectedToHost() { return h.RespondWithError(c, errors.New("not connected to host")) } // Send join request to host err := h.App.NakamaManager.GetWatchPartyManager().JoinWatchParty() if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } // HandleNakamaLeaveWatchParty // // @summary leaves the current watch party. // @desc This removes the user from the active watch party session. // @route /api/v1/nakama/watch-party/leave [POST] // @returns bool func (h *Handler) HandleNakamaLeaveWatchParty(c echo.Context) error { if h.App.Settings.GetNakama().IsHost { // Host stopping the watch party h.App.NakamaManager.GetWatchPartyManager().StopWatchParty() } else { // Peer leaving the watch party if !h.App.NakamaManager.IsConnectedToHost() { return h.RespondWithError(c, errors.New("not connected to host")) } err := h.App.NakamaManager.GetWatchPartyManager().LeaveWatchParty() if err != nil { return h.RespondWithError(c, err) } } return h.RespondWithData(c, true) }