package web import ( "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/hotkeys" "gitgud.io/mike/mpv-manager/pkg/log" ) // ============================================================================ // Hotkey Preset API Endpoints // ============================================================================ // handleHotkeyPresetsAPI returns the list of available hotkey preset profiles. // GET /api/hotkeys/presets func (s *Server) handleHotkeyPresetsAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } presets := hotkeys.GetPresetList() // Determine the active preset by comparing the current input.conf against presets. activePreset := detectActivePreset() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "presets": presets, "active_preset": activePreset, }) } // handleHotkeyApplyPresetAPI applies a hotkey preset profile. // POST /api/hotkeys/apply func (s *Server) handleHotkeyApplyPresetAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } var req struct { Preset string `json:"preset"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Validate preset name if err := ValidateHotkeyPreset(req.Preset); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } configPath := hotkeys.GetInputConfPath() if configPath == "" { sendJSONError(w, http.StatusInternalServerError, "Failed to determine input.conf path") return } if err := hotkeys.ApplyPreset(req.Preset, configPath); err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply hotkey preset: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to apply preset: "+err.Error()) return } // Find the display name for the preset presetName := req.Preset for _, p := range hotkeys.GetPresetList() { if p.ID == req.Preset { presetName = p.Name break } } log.Info(constants.LogPrefixAPI + "Applied hotkey preset: " + req.Preset) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": presetName + " keybindings applied successfully", }) } // handleHotkeyResetAPI resets keybindings to mpv defaults. // POST /api/hotkeys/reset func (s *Server) handleHotkeyResetAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } configPath := hotkeys.GetInputConfPath() if configPath == "" { sendJSONError(w, http.StatusInternalServerError, "Failed to determine input.conf path") return } if err := hotkeys.ApplyPreset("default", configPath); err != nil { log.Error(constants.LogPrefixAPI + "Failed to reset hotkeys: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to reset keybindings: "+err.Error()) return } log.Info(constants.LogPrefixAPI + "Reset keybindings to mpv defaults") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Keybindings reset to mpv defaults", }) } // handleHotkeyCurrentAPI returns the user's current keybindings. // GET /api/hotkeys/current func (s *Server) handleHotkeyCurrentAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } bindings, err := hotkeys.GetActiveBindings() if err != nil { sendJSONError(w, http.StatusInternalServerError, "Failed to read keybindings: "+err.Error()) return } // Detect if user has a custom (non-preset) config hasCustom := detectActivePreset() == "" // Convert bindings to a serializable format type bindingEntry struct { Key string `json:"key"` Command string `json:"command"` Comment string `json:"comment"` } entries := make([]bindingEntry, 0, len(bindings)) for _, b := range bindings { entries = append(entries, bindingEntry{ Key: b.Key, Command: b.Command, Comment: b.Comment, }) } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "bindings": entries, "has_custom": hasCustom, "binding_count": len(entries), }) } // detectActivePreset compares the current input.conf to each known preset // and returns the preset ID if it matches, or "" for a custom config. // It compares raw file bytes against raw preset bytes to avoid false // negatives caused by parse→serialize whitespace normalization. func detectActivePreset() string { configPath := hotkeys.GetInputConfPath() if configPath == "" { return "" } rawFile, err := os.ReadFile(configPath) if err != nil || len(rawFile) == 0 { // No file or empty file means mpv uses built-in defaults return "default" } fileContent := strings.TrimSpace(string(rawFile)) for _, preset := range hotkeys.GetPresetList() { presetContent, err := hotkeys.LoadPreset(preset.ID) if err != nil { continue } if strings.TrimSpace(presetContent) == fileContent { return preset.ID } } // No preset matched — it's a custom config return "" } // handleHotkeyBindingSetAPI adds or updates a single key binding in input.conf. // If the key is already bound and overwrite is not set, it responds with // 409 and the existing binding so the client can confirm. // POST /api/hotkeys/bindings/set func (s *Server) handleHotkeyBindingSetAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } var req struct { Key string `json:"key"` Command string `json:"command"` Overwrite bool `json:"overwrite"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } if err := hotkeys.ValidateKeyName(req.Key); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } if err := hotkeys.ValidateBindingCommand(req.Command); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } configPath := hotkeys.GetInputConfPath() if configPath == "" { sendJSONError(w, http.StatusInternalServerError, "Failed to determine input.conf path") return } existing, err := hotkeys.SetBindingFile(configPath, req.Key, req.Command, "", req.Overwrite) if errors.Is(err, hotkeys.ErrBindingConflict) { w.Header().Set("Content-Type", constants.ContentTypeJSON) w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]interface{}{ "conflict": true, "existing": map[string]string{ "key": existing.Key, "command": existing.Command, }, }) return } if err != nil { log.Error(constants.LogPrefixAPI + "Failed to write input.conf: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to write keybindings: "+err.Error()) return } log.Info(constants.LogPrefixAPI + "Set keybinding: " + req.Key + " -> " + req.Command) response := map[string]interface{}{ "success": true, } if backup := latestInputConfBackup(configPath); backup != "" { response["backup"] = backup } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(response) } // handleHotkeyBindingRemoveAPI removes a single key binding from input.conf. // POST /api/hotkeys/bindings/remove func (s *Server) handleHotkeyBindingRemoveAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } var req struct { Key string `json:"key"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } if err := hotkeys.ValidateKeyName(req.Key); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } configPath := hotkeys.GetInputConfPath() if configPath == "" { sendJSONError(w, http.StatusInternalServerError, "Failed to determine input.conf path") return } removed, err := hotkeys.RemoveBindingFile(configPath, req.Key) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to update input.conf: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to write keybindings: "+err.Error()) return } if !removed { sendJSONError(w, http.StatusNotFound, "No binding found for key: "+req.Key) return } log.Info(constants.LogPrefixAPI + "Removed keybinding: " + req.Key) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) } // latestInputConfBackup returns the most recent timestamped backup created for // the given input.conf path, or "" if none exists. The input.conf editor names // backups "-input.conf.bak." with a sortable timestamp, so // the last glob match is the newest. func latestInputConfBackup(configPath string) string { matches, err := filepath.Glob(configPath + "-input.conf.bak.*") if err != nil || len(matches) == 0 { return "" } return matches[len(matches)-1] }