784 lines
25 KiB
Go
784 lines
25 KiB
Go
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)
|
|
}
|