package web import ( "encoding/json" "fmt" "net/http" "os" "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/platform" "gitgud.io/mike/mpv-manager/pkg/version" ) // APIResponse is a standard JSON response structure for API endpoints type APIResponse struct { Status string `json:"status"` Message string `json:"message,omitempty"` } func (s *Server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "online", }) } func (s *Server) handlePlatformAPI(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "os": string(s.platform.OSType), "arch": s.platform.Arch, "distro": s.platform.Distro, "version": s.platform.Version, "cpu": map[string]interface{}{ "vendor": s.platform.CPUInfo.Vendor, "vendor_display": s.platform.CPUInfo.VendorDisplay, "architecture_level": s.platform.CPUInfo.ArchitectureLevel, "model": s.platform.CPUInfo.Model, "features": s.platform.CPUInfo.Features, "supports_avx2": s.platform.CPUInfo.SupportsAVX2, "supports_avx512": s.platform.CPUInfo.SupportsAVX512, "supports_neon": s.platform.CPUInfo.SupportsNEON, }, "gpu": map[string]interface{}{ "models": s.platform.GPUInfo.Models, "vendor": s.platform.GPUInfo.Brand, "codecs": s.platform.GPUInfo.SupportedCodecs, }, } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(response) } func (s *Server) handleAppsAPI(w http.ResponseWriter, r *http.Request) { installedApps := config.GetInstalledApps() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "apps": installedApps, }) } func (s *Server) handleInstallMethodsAPI(w http.ResponseWriter, r *http.Request) { methods := s.getInstallMethods() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "methods": methods, }) } type InstallRequest struct { AppID string `json:"app_id,omitempty"` MethodID string `json:"method_id"` InstallUOSC bool `json:"install_uosc"` UIType string `json:"ui_type,omitempty"` // "uosc", "modernz", or "none" } type UninstallRequest struct { AppID string `json:"app_id,omitempty"` AppName string `json:"app_name"` AppType string `json:"app_type"` InstallMethod string `json:"install_method"` InstallPath string `json:"install_path,omitempty"` } func (s *Server) handleCheckAppUpdatesAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if !s.refreshRequestedManifest(w, r) { return } log.Info("[API] Checking for app updates...") // Refresh version check cache s.refreshVersionCache(r.Context()) // Get update items from a snapshot of the cache (lock released before conversion) updateItems, _, pkgVersions := s.versionCacheSnapshot() // Convert updates to client update info (this filters correctly) clientUpdates := convertClientUpdates(updateItems, pkgVersions) data := struct { ClientUpdates []ClientUpdateInfo }{ ClientUpdates: clientUpdates, } w.Header().Set("Content-Type", constants.ContentTypeHTML) err := s.templates.ExecuteTemplate(w, "available-updates", data) if err != nil { log.Error("Failed to render available-updates template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) return } } // handleRefreshInstalledAppsAPI refreshes the version cache and returns the // installed apps list, available updates, AND install methods. // Uses HTMX OOB swap to update all three sections. func (s *Server) handleRefreshInstalledAppsAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } if !s.refreshRequestedManifest(w, r) { return } log.Info("[API] Refreshing installed apps, updates, and install methods...") // Refresh version check cache s.refreshVersionCache(r.Context()) // Get installed apps installedApps := config.GetInstalledApps() // Get update map from a snapshot of the cache (lock released before conversion) updateItems, updateMap, pkgVersions := s.versionCacheSnapshot() // Group apps with update info appGroups := s.groupInstalledAppsWithUpdates(installedApps, updateMap) // Convert updates to client update info clientUpdates := convertClientUpdates(updateItems, pkgVersions) // Get install methods installMethods := s.getInstallMethods() data := struct { InstalledAppGroups map[string][]AppGroupInfo ClientUpdates []ClientUpdateInfo InstallMethods []installer.InstallMethod Platform *platform.Platform }{ InstalledAppGroups: appGroups, ClientUpdates: clientUpdates, InstallMethods: installMethods, Platform: s.platform, } w.Header().Set("Content-Type", constants.ContentTypeHTML) err := s.templates.ExecuteTemplate(w, "apps-refresh-response", data) if err != nil { log.Error("Failed to render apps-refresh-response template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (s *Server) handleManagerUpdateAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } log.Info("[API] Manager update request received") if version.SelfUpdateDisabled == "true" { log.Info("[API] Manager update rejected: self-update is disabled in this build") sendJSONError(w, http.StatusForbidden, "Self-update is disabled in this build (installed via package manager)") return } job, created := s.jobManager.CreateJobWithResources("manager-update", version.ComponentID, "MPV Manager", resourceManagerBinary) if !created { sendJSONError(w, http.StatusConflict, "A manager update is already in progress") return } s.jobManager.StartWorker(job.ID, func() { s.runManagerUpdateJob(job.ID) }) w.Header().Set("Content-Type", constants.ContentTypeJSON) w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", "action": "job_started", "message": "Manager update started in the background", }) } func (s *Server) runManagerUpdateJob(jobID string) { job, exists := s.jobManager.GetJob(jobID) if !exists { return } ctx := job.Context() lastPhase := version.SelfUpdateChecking fail := func(err error) { if lastPhase != version.SelfUpdateRolledBack { lastPhase = version.SelfUpdateFailed s.jobManager.UpdatePhase(jobID, string(lastPhase), 100, "Manager update failed") } s.jobManager.SetError(jobID, err, "The existing manager installation remains available. Review the task output for details.") log.Error("[API] Manager update failed: " + err.Error()) } defer func() { if recovered := recover(); recovered != nil { fail(fmt.Errorf("manager update panic: %v", recovered)) } }() s.jobManager.UpdatePhase(jobID, string(lastPhase), managerUpdateProgress(lastPhase, 0, 0), "Checking for manager updates") s.jobManager.AddOutput(jobID, "Checking for manager updates") checkForUpdate := s.managerUpdateCheck if checkForUpdate == nil { checkForUpdate = version.CheckForUpdate } check := checkForUpdate() if check == nil { fail(fmt.Errorf("update check returned no release selection")) return } if check.Error != nil { fail(check.Error) return } if !check.UpdateAvailable { fail(fmt.Errorf("no update available")) return } s.jobManager.AddOutput(jobID, "Selected MPV Manager "+check.LatestVersion) resolveExecutable := s.executablePath if resolveExecutable == nil { resolveExecutable = os.Executable } executablePath, err := resolveExecutable() if err != nil { fail(fmt.Errorf("resolve executable path: %w", err)) return } prepareUpdate := s.managerUpdatePrepare if prepareUpdate == nil { prepareUpdate = func(path string, check *version.VersionCheckResult, progress version.SelfUpdateProgressCallback) (*version.PreparedSelfUpdate, error) { return version.PrepareSelfUpdateFromCheckContext(ctx, path, check, progress) } } lastReportedPhase := lastPhase prepared, err := prepareUpdate(executablePath, check, func(progress version.SelfUpdateProgress) { lastPhase = progress.Phase percent := managerUpdateProgress(progress.Phase, progress.Written, progress.Total) s.jobManager.UpdatePhase(jobID, string(progress.Phase), percent, progress.Message) if progress.Phase != lastReportedPhase { s.jobManager.AddOutput(jobID, progress.Message) lastReportedPhase = progress.Phase } }) if err != nil { fail(err) return } if err := s.jobManager.BeginCommit(jobID); err != nil { _ = prepared.Abort() fail(err) return } if prepared.JournalPath != "" { if err := prepared.SetOriginJobID(jobID); err != nil { _ = prepared.Abort() fail(fmt.Errorf("bind manager update task to helper outcome: %w", err)) return } } launchUpdate := s.managerUpdateLaunch if launchUpdate == nil { launchUpdate = func(prepared *version.PreparedSelfUpdate, mode string) error { return prepared.LaunchUpdateHelper(mode) } } if err := launchUpdate(prepared, "none"); err != nil { _ = prepared.Abort() fail(err) return } lastPhase = version.SelfUpdateReadyToRestart s.jobManager.UpdatePhase(jobID, string(lastPhase), managerUpdateProgress(lastPhase, 0, 0), "Update verified; MPV Manager will close and install it automatically") s.jobManager.AddOutput(jobID, "Update verified; helper handoff complete") lastPhase = version.SelfUpdateRestarting s.jobManager.UpdatePhase(jobID, string(lastPhase), managerUpdateProgress(lastPhase, 0, 0), "Closing MPV Manager to finish the update") s.jobManager.SetHandedOff(jobID, prepared.TransactionID) log.Info("[API] Manager update helper started; scheduled graceful shutdown") shutdownDelay := s.managerUpdateShutdownDelay if shutdownDelay <= 0 { shutdownDelay = time.Duration(updateManagerSuccessAutoCloseSeconds+1) * time.Second } s.scheduleShutdown(shutdownDelay) } func managerUpdateProgress(phase version.SelfUpdatePhase, written, total int64) int { switch phase { case version.SelfUpdateChecking: return 2 case version.SelfUpdateDownloading: if total <= 0 { return 5 } progress := 5 + int((written*70)/total) if progress > 75 { return 75 } return progress case version.SelfUpdateVerifying: return 80 case version.SelfUpdateCommitted: return 90 case version.SelfUpdateReadyToRestart: return 95 case version.SelfUpdateRestarting: return 99 case version.SelfUpdateRolledBack, version.SelfUpdateFailed: return 100 default: return 0 } }