Files
silo-server/internal/api/handlers/admin_playback_control.go
T

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
}