236 lines
6.9 KiB
Go
236 lines
6.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
)
|
|
|
|
const (
|
|
defaultPlaybackControlDeadline = 3 * time.Second
|
|
maxPlaybackControlDeadline = 10 * time.Second
|
|
)
|
|
|
|
type AdminPlaybackControlHandler struct {
|
|
playback *PlaybackHandler
|
|
}
|
|
|
|
type playbackControlRequest struct {
|
|
Reason string `json:"reason"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
DeadlineMS int `json:"deadline_ms"`
|
|
}
|
|
|
|
type playbackControlResponse struct {
|
|
CommandID string `json:"command_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func requiresLivePlaybackControl(name playback.CommandName) bool {
|
|
switch name {
|
|
case playback.CommandPause, playback.CommandUnpause:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func NewAdminPlaybackControlHandler(playbackHandler *PlaybackHandler) *AdminPlaybackControlHandler {
|
|
return &AdminPlaybackControlHandler{playback: playbackHandler}
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) HandleStopSession(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSessionCommand(w, r, playback.CommandStop)
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) HandlePauseSession(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSessionCommand(w, r, playback.CommandPause)
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) HandleResumeSession(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSessionCommand(w, r, playback.CommandUnpause)
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) HandleTerminateSession(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSessionCommand(w, r, playback.CommandTerminate)
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter, r *http.Request) {
|
|
if h == nil || h.playback == nil || h.playback.CommandDispatcher == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Playback control is unavailable")
|
|
return
|
|
}
|
|
|
|
sessionID := chi.URLParam(r, "session_id")
|
|
if sessionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
|
return
|
|
}
|
|
|
|
if _, err := h.playback.sessionMgr.GetSession(sessionID); err != nil {
|
|
if errors.Is(err, playback.ErrSessionNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Playback session not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
|
return
|
|
}
|
|
|
|
var req playbackControlRequest
|
|
if err := decodeOptionalJSONBody(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.Message == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Message is required")
|
|
return
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{
|
|
"title": req.Title,
|
|
"message": req.Message,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command payload")
|
|
return
|
|
}
|
|
|
|
commandID := uuid.NewString()
|
|
command, err := playback.NewCommandEnvelope(sessionID, commandID, playback.CommandDisplayMessage, payload)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command")
|
|
return
|
|
}
|
|
command.Reason = req.Reason
|
|
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
|
|
|
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
|
|
if result.DispatchErr != nil {
|
|
status := http.StatusInternalServerError
|
|
code := "internal_error"
|
|
message := "Failed to dispatch command"
|
|
if errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
|
|
status = http.StatusConflict
|
|
code = "realtime_unavailable"
|
|
message = "Realtime connection unavailable for playback session"
|
|
}
|
|
writeError(w, status, code, message)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, playbackControlResponse{
|
|
CommandID: commandID,
|
|
Status: "dispatched",
|
|
})
|
|
}
|
|
|
|
func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter, r *http.Request, name playback.CommandName) {
|
|
if h == nil || h.playback == nil || h.playback.CommandDispatcher == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Playback control is unavailable")
|
|
return
|
|
}
|
|
|
|
sessionID := chi.URLParam(r, "session_id")
|
|
if sessionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
|
return
|
|
}
|
|
|
|
session, err := h.playback.sessionMgr.GetSession(sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, playback.ErrSessionNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Playback session not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
|
return
|
|
}
|
|
|
|
var req playbackControlRequest
|
|
if err := decodeOptionalJSONBody(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if requiresLivePlaybackControl(name) && (session == nil || !session.HasRealtimeConnection) {
|
|
writeError(w, http.StatusConflict, "realtime_unavailable", "Realtime connection unavailable for playback session")
|
|
return
|
|
}
|
|
|
|
commandID := uuid.NewString()
|
|
command, err := playback.NewCommandEnvelope(sessionID, commandID, name, nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command")
|
|
return
|
|
}
|
|
command.Reason = req.Reason
|
|
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
|
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
|
|
command.DeadlineMS = int(deadline / time.Millisecond)
|
|
|
|
fallback := func() {
|
|
h.playback.forgetRealtimeCommand(commandID)
|
|
_ = h.playback.stopPlaybackSessionByID(context.Background(), sessionID)
|
|
}
|
|
|
|
h.playback.rememberRealtimeCommand(commandID, sessionID, name)
|
|
result := h.playback.CommandDispatcher.DispatchToSession(command, deadline, fallback)
|
|
if result.DispatchErr == nil {
|
|
writeJSON(w, http.StatusAccepted, playbackControlResponse{
|
|
CommandID: commandID,
|
|
Status: "dispatched",
|
|
})
|
|
return
|
|
}
|
|
|
|
h.playback.forgetRealtimeCommand(commandID)
|
|
if errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
|
|
time.AfterFunc(deadline, fallback)
|
|
writeJSON(w, http.StatusAccepted, playbackControlResponse{
|
|
CommandID: commandID,
|
|
Status: "fallback_scheduled",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dispatch command")
|
|
}
|
|
|
|
func decodeOptionalJSONBody(r *http.Request, target any) error {
|
|
if r.Body == nil {
|
|
return nil
|
|
}
|
|
defer r.Body.Close()
|
|
decoder := json.NewDecoder(r.Body)
|
|
if err := decoder.Decode(target); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if errors.Is(err, http.ErrBodyNotAllowed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boundedPlaybackControlDeadline(deadlineMS int) time.Duration {
|
|
if deadlineMS <= 0 {
|
|
return defaultPlaybackControlDeadline
|
|
}
|
|
deadline := time.Duration(deadlineMS) * time.Millisecond
|
|
if deadline > maxPlaybackControlDeadline {
|
|
return maxPlaybackControlDeadline
|
|
}
|
|
return deadline
|
|
}
|