package web import ( "encoding/json" "fmt" "html" "io" "net/http" "os" "path/filepath" "regexp" "runtime" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/version" ) // fetchReleaseInfo fetches the release manifest. It is a package-level // variable so tests can stub out network access. var fetchReleaseInfo = version.FetchReleaseInfoContext const maxDisplayedLogBytes int64 = 2 << 20 // manifestStatus reports whether the release manifest is available and, if // so, its version. The manifest is unavailable when the startup fetch failed // (nil or empty Version); installs and updates cannot proceed without it. func (s *Server) manifestStatus() (available bool, manifestVersion string) { s.releaseInfoMux.RLock() defer s.releaseInfoMux.RUnlock() if s.releaseInfo == nil || s.releaseInfo.Version == "" { return false, "" } return true, s.releaseInfo.Version } // handleManifestStatusAPI reports whether the release manifest is available. func (s *Server) handleManifestStatusAPI(w http.ResponseWriter, r *http.Request) { available, manifestVersion := s.manifestStatus() response := map[string]interface{}{ "available": available, } if available { response["version"] = manifestVersion } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(response) } // handleManifestRefreshAPI re-fetches the release manifest after a startup // failure and publishes a new immutable snapshot. Existing jobs retain the // manifest they started with. func (s *Server) handleManifestRefreshAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } log.Info(constants.LogPrefixAPI + "Refreshing release manifest...") newInfo, err := fetchReleaseInfo(r.Context()) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to refresh release manifest: " + err.Error()) w.Header().Set("Content-Type", constants.ContentTypeJSON) w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]interface{}{ "available": false, "error": err.Error(), }) return } s.releaseInfoMux.Lock() s.releaseInfo = newInfo s.releaseInfoMux.Unlock() log.Info(constants.LogPrefixAPI + "Release manifest refreshed: version " + newInfo.Version) // Rebuild the version cache so update checks pick up the new manifest. // Safe to call here: it takes versionCacheMux, not releaseInfoMux. s.refreshVersionCache(r.Context()) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "available": true, "version": newInfo.Version, }) } // refreshRequestedManifest distinguishes the explicit update-check buttons from // automatic post-job cache refreshes. Existing jobs keep their original snapshot. func (s *Server) refreshRequestedManifest(w http.ResponseWriter, r *http.Request) bool { if r.URL.Query().Get("refresh_manifest") != "true" { return true } info, err := fetchReleaseInfo(r.Context()) if err != nil { sendJSONError(w, http.StatusServiceUnavailable, "Could not check for updates: "+err.Error()) return false } s.releaseInfoMux.Lock() s.releaseInfo = info s.releaseInfoMux.Unlock() // Re-render manager status and install choices as well as player updates. w.Header().Set("HX-Refresh", "true") return true } func (s *Server) handleFileAssociationsSetupAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "File associations only available on Windows") return } var request struct { AppID string `json:"app_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.AppID == "" { sendJSONError(w, http.StatusBadRequest, "Select an installed MPV app") return } app, err := config.ResolveInstalledApp(request.AppID, "", "", "") if err != nil || !app.Managed || (app.InstallMethod != constants.MethodMPVBinary && app.InstallMethod != constants.MethodMPVBinaryV3) { sendJSONError(w, http.StatusBadRequest, "Select a managed MPV installation") return } inst := s.installerForOperation(app.InstallPath) handler := installer.NewInstallationHandler(s.platform, inst) if handler == nil { sendJSONError(w, http.StatusInternalServerError, "Failed to create installer") return } outputChan := make(chan string, 100) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err = handler.PlatformInstaller.SetupFileAssociationsWithOutput(cr) if err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "message": "Choose the file types to open with mpv in Windows Settings.", }) } func (s *Server) handleFileAssociationsRemoveAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "File associations only available on Windows") return } var request struct { AppID string `json:"app_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.AppID == "" { sendJSONError(w, http.StatusBadRequest, "Select an installed MPV app") return } app, err := config.ResolveInstalledApp(request.AppID, "", "", "") if err != nil || !app.Managed || (app.InstallMethod != constants.MethodMPVBinary && app.InstallMethod != constants.MethodMPVBinaryV3) { sendJSONError(w, http.StatusBadRequest, "Select a managed MPV installation") return } inst := s.installerForOperation(app.InstallPath) handler := installer.NewInstallationHandler(s.platform, inst) if handler == nil { sendJSONError(w, http.StatusInternalServerError, "Failed to create installer") return } outputChan := make(chan string, 100) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err = handler.PlatformInstaller.RemoveFileAssociationsWithOutput(cr) if err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "message": "File associations removed successfully", }) } func (s *Server) handleShortcutsCreateAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "Shortcuts only available on Windows") return } var request struct { AppID string `json:"app_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.AppID == "" { sendJSONError(w, http.StatusBadRequest, "Select an installed MPV app") return } app, err := config.ResolveInstalledApp(request.AppID, "", "", "") if err != nil || (app.InstallMethod != constants.MethodMPVBinary && app.InstallMethod != constants.MethodMPVBinaryV3) || !filepath.IsAbs(app.InstallPath) { sendJSONError(w, http.StatusBadRequest, "Select an installed MPV app") return } release, acquired := s.jobManager.TryAcquireResources("shortcut:"+newJobID(), "app:"+app.ID, "install-path:"+normalizedLeasePath(app.InstallPath)) if !acquired { sendJSONError(w, http.StatusConflict, "An operation is already using this installation") return } defer release() inst := s.installerForOperation(app.InstallPath) handler := installer.NewInstallationHandler(s.platform, inst) if handler == nil { sendJSONError(w, http.StatusInternalServerError, "Failed to create installer") return } // Check if MPV is installed (check install path) installPath := app.InstallPath mpvPath := filepath.Join(installPath, "mpv.exe") if _, err := os.Stat(mpvPath); os.IsNotExist(err) { sendJSONError(w, http.StatusNotFound, "MPV is not installed") return } outputChan := make(chan string, 100) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err = handler.CreateMPVShortcutWithOutput(cr) if err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "message": "Desktop and Start Menu shortcuts created successfully", }) } func (s *Server) handleLogsAPI(w http.ResponseWriter, r *http.Request) { logPath := log.GetFilePath() logContent, err := readFileTail(logPath, maxDisplayedLogBytes) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // HTML-escape log content: the Logs page swaps this response into the DOM // via innerHTML, so embedded markup from log lines must not execute. The // content type is text/html because the payload is escaped HTML. w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html.EscapeString(string(logContent)))) } func (s *Server) handleLogsClearAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if err := log.Clear(); err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", }) } func (s *Server) handleLogsDownloadAPI(w http.ResponseWriter, r *http.Request) { logPath := log.GetFilePath() w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=mpv-manager.log") http.ServeFile(w, r, logPath) } type LogError struct { Timestamp string `json:"timestamp"` Message string `json:"message"` Details string `json:"details"` } // logsPageData extends DashboardData with live logger state for the Logs page. type logsPageData struct { DashboardData LogFilePath string DebugMode bool VerboseMode bool } // logLineTimestampPattern matches the "[2006-01-02 15:04:05]" prefix that // starts every log line written by pkg/log. var logLineTimestampPattern = regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`) func (s *Server) handleRecentErrorsAPI(w http.ResponseWriter, r *http.Request) { logPath := log.GetFilePath() content, err := readFileTail(logPath, maxDisplayedLogBytes) if err != nil { log.Error("Failed to read log file: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) return } errors := parseLogErrors(string(content), 5) w.Header().Set("Content-Type", constants.ContentTypeHTML) err = s.templates.ExecuteTemplate(w, "recent-errors", errors) if err != nil { log.Error("Failed to render recent-errors template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) } } func readFileTail(path string, maxBytes int64) ([]byte, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() info, err := file.Stat() if err != nil { return nil, err } if info.Size() > maxBytes { if _, err := file.Seek(info.Size()-maxBytes, io.SeekStart); err != nil { return nil, err } } return io.ReadAll(io.LimitReader(file, maxBytes)) } func parseLogErrors(logContent string, maxErrors int) []LogError { var errors []LogError lines := strings.Split(logContent, "\n") // Only process the last 50 lines for efficiency if len(lines) > 50 { lines = lines[len(lines)-50:] } for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) if strings.Contains(line, "[ERROR]") || strings.Contains(line, "[FATAL]") { parts := strings.SplitN(line, "] ", 2) if len(parts) >= 2 { timestamp := strings.Trim(parts[0], "[") message := strings.TrimPrefix(parts[1], "[ERROR] ") message = strings.TrimPrefix(message, "[FATAL] ") var details string for j := i + 1; j < len(lines) && j < i+3; j++ { next := strings.TrimSpace(lines[j]) if next == "" { continue } // Stop at the next log entry: timestamped lines belong to // a different message, not to this error's details. if logLineTimestampPattern.MatchString(next) { break } details += next + "\n" } errors = append(errors, LogError{ Timestamp: timestamp, Message: message, Details: strings.TrimSpace(details), }) if len(errors) >= maxErrors { break } } } } for i, j := 0, len(errors)-1; i < j; i, j = i+1, j-1 { errors[i], errors[j] = errors[j], errors[i] } return errors } func (s *Server) handleShutdownAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } log.Info("Shutdown requested via Web UI") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": constants.StatusShutdown, "message": constants.ResponseMessageShutdown, }) // Flush the response to ensure it's sent before shutdown if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } // Shutdown in background to allow response to complete delay := s.shutdownRequestDelay if delay <= 0 { delay = 500 * time.Millisecond } s.scheduleShutdown(delay) } // ============================================================================ // Settings API Endpoints // ============================================================================ // getDefaultInstallPath returns the install location used when no custom // install path is configured. Mirrors the default resolution in pkg/config // (detected MPV base dir on Windows, ~/mpv elsewhere). func getDefaultInstallPath() string { if runtime.GOOS == constants.OSWindows { return constants.GetWindowsMPVBaseDir() } homeDir, err := os.UserHomeDir() if err != nil { return filepath.Join(".", "mpv") } return filepath.Join(homeDir, "mpv") } // handleSettingsAPI returns current settings for the settings page // GET /api/settings // install_path is the raw configured custom path ("" when unset, so the // settings page does not pre-fill an explicit override); default_install_path // is the resolved platform default shown as a hint. func (s *Server) handleSettingsAPI(w http.ResponseWriter, r *http.Request) { configPath := getConfigFilePath() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "desktop_shortcut_enabled": config.GetCreateManagerShortcut(), "config_file_path": configPath, "install_path": config.GetConfiguredInstallPath(), "default_install_path": getDefaultInstallPath(), }) } // handleSettingsShortcutAPI toggles desktop shortcut preference // POST /api/settings/shortcut func (s *Server) handleSettingsShortcutAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } var req struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Update the preference if err := config.SetCreateManagerShortcut(req.Enabled); err != nil { log.Error(constants.LogPrefixAPI + "Failed to set shortcut preference: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to save preference") return } log.Info(fmt.Sprintf(constants.LogPrefixAPI+"Desktop shortcut preference set to: %v", req.Enabled)) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "enabled": req.Enabled, }) } // handleSettingsResetAPI resets the mpv-manager.json file to defaults // POST /api/settings/reset func (s *Server) handleSettingsResetAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } // Use config package to reset if err := config.ResetToDefaults(); err != nil { log.Error(constants.LogPrefixAPI + "Failed to reset config: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, "Failed to reset config file") return } log.Info(constants.LogPrefixAPI + "MPV Manager data reset successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "message": "MPV Manager data has been reset to defaults", }) } // handleSettingsInstallPathAPI sets or resets the custom mpv install location // POST /api/settings/install-path // Body: {"install_path": "/custom/dir"}. An empty install_path resets to the // platform default. func (s *Server) handleSettingsInstallPathAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } var req struct { InstallPath string `json:"install_path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } if req.InstallPath == "" { // Reset to the platform default install path. if err := config.ClearInstallPath(); err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } else { if err := config.ValidateCustomInstallPath(req.InstallPath); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } if err := config.SetInstallPath(req.InstallPath); err != nil { sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } effectivePath := config.GetInstallPath() log.Info(constants.LogPrefixAPI + "Install path set to: " + effectivePath) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "install_path": effectivePath, }) } // handleNoticeAPI returns and clears any pending notice for the Web UI // GET returns the notice, DELETE clears it func (s *Server) handleNoticeAPI(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: notice := s.GetPendingNotice() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "notice": notice, }) case http.MethodDelete: s.GetAndClearPendingNotice() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "cleared", }) default: sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") } }