package web import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "time" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/hotkeys" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/keyring" "gitgud.io/mike/mpv-manager/pkg/locale" "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"` } // sendJSONError sends a JSON error response with proper Content-Type header func sendJSONError(w http.ResponseWriter, statusCode int, message string) { w.Header().Set("Content-Type", constants.ContentTypeJSON) w.WriteHeader(statusCode) json.NewEncoder(w).Encode(APIResponse{ Status: "error", Message: message, }) } 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, }) } func (s *Server) handleUpdatesAPI(w http.ResponseWriter, r *http.Request) { installerCheck := version.CheckForUpdate() installedApps := config.GetInstalledApps() appUpdates := version.CheckForAppUpdates(s.releaseInfo, installedApps) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "installer": map[string]interface{}{ "current_version": installerCheck.CurrentVersion, "latest_version": installerCheck.LatestVersion, "update_available": installerCheck.UpdateAvailable, }, "apps": appUpdates.AppsToUpdate, "total_updates": len(appUpdates.AppsToUpdate), }) } func (s *Server) handleConfigSettingsAPI(w http.ResponseWriter, r *http.Request) { audioLangs := config.GetConfigValue(constants.ConfigKeyAudioLanguage) subtitleLangs := config.GetConfigValue(constants.ConfigKeySubtitleLanguage) hwaValue := "auto" var audioLang, subtitleLang string if len(audioLangs) > 0 { audioLang = audioLangs[0] } if len(subtitleLangs) > 0 { subtitleLang = subtitleLangs[0] } // Load screenshot settings screenshotFormat := getMPVConfigValue(constants.ConfigKeyScreenshotFormat) screenshotTagColorspace := getMPVConfigValue(constants.ConfigKeyScreenshotTagColorspace) screenshotHighBitDepth := getMPVConfigValue(constants.ConfigKeyScreenshotHighBitDepth) screenshotTemplate := getMPVConfigValue(constants.ConfigKeyScreenshotTemplate) screenshotDirectory := getMPVConfigValue(constants.ConfigKeyScreenshotDirectory) screenshotJpegQuality := getMPVConfigValue(constants.ConfigKeyScreenshotJpegQuality) screenshotPngCompression := getMPVConfigValue(constants.ConfigKeyScreenshotPngCompression) screenshotWebpLossless := getMPVConfigValue(constants.ConfigKeyScreenshotWebpLossless) screenshotWebpQuality := getMPVConfigValue(constants.ConfigKeyScreenshotWebpQuality) screenshotJxlDistance := getMPVConfigValue(constants.ConfigKeyScreenshotJxlDistance) screenshotAvifEncoder := getMPVConfigValue(constants.ConfigKeyScreenshotAvifEncoder) screenshotAvifPixfmt := getMPVConfigValue(constants.ConfigKeyScreenshotAvifPixfmt) screenshotAvifOpts := getMPVConfigValue(constants.ConfigKeyScreenshotAvifOpts) // Load additional settings savePositionOnQuit := getMPVConfigValue(constants.ConfigKeySavePositionOnQuit) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "audio_language": audioLang, "subtitle_language": subtitleLang, "hardware_acceleration": hwaValue, "config_file": getConfigFilePath(), "save_position_on_quit": savePositionOnQuit, "screenshot_format": screenshotFormat, "screenshot_tag_colorspace": screenshotTagColorspace, "screenshot_high_bit_depth": screenshotHighBitDepth, "screenshot_template": screenshotTemplate, "screenshot_directory": screenshotDirectory, "screenshot_jpeg_quality": screenshotJpegQuality, "screenshot_png_compression": screenshotPngCompression, "screenshot_webp_lossless": screenshotWebpLossless, "screenshot_webp_quality": screenshotWebpQuality, "screenshot_jxl_distance": screenshotJxlDistance, "screenshot_avif_encoder": screenshotAvifEncoder, "screenshot_avif_pixfmt": screenshotAvifPixfmt, "screenshot_avif_opts": screenshotAvifOpts, }) } func (s *Server) handleConfigBackupsAPI(w http.ResponseWriter, r *http.Request) { backups := getConfigBackups() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "backups": backups, }) } // handleConfigBackupsListAPI returns HTML fragment for backup list // Used for HTMX partial refresh after restore/delete operations func (s *Server) handleConfigBackupsListAPI(w http.ResponseWriter, r *http.Request) { backups := getConfigBackups() data := struct { ConfigBackups []ConfigBackup }{ ConfigBackups: backups, } w.Header().Set("Content-Type", constants.ContentTypeHTML) err := s.templates.ExecuteTemplate(w, "config-backups-list", data) if err != nil { log.Error("Failed to render config-backups-list template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleInstallAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode install request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } log.Info(constants.LogPrefixAPI + "Install request received: method_id=" + req.MethodID + ", ui_type=" + req.UIType) // Determine UI type, with backwards compatibility for install_uosc uiType := req.UIType if uiType == "" { // Fall back to install_uosc for backwards compatibility if req.InstallUOSC { uiType = constants.UITypeUOSC } else { uiType = constants.UITypeNone } } session := s.sessions.Create(req.MethodID) go s.runInstallation(session, req.MethodID, uiType, false) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "session_id": session.ID, "status": "started", }) } type InstallRequest struct { MethodID string `json:"method_id"` InstallUOSC bool `json:"install_uosc"` UIType string `json:"ui_type,omitempty"` // "uosc", "modernz", or "none" } func (s *Server) runInstallation(session *Session, methodID string, uiType string, isUpdate bool) { p := s.platform inst := s.installer outputChan := make(chan string, 1000) errorChan := make(chan error, 10) // Create a context for this installation (sessions don't support cancellation yet) ctx := context.Background() cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) go func() { for output := range outputChan { session.OutputChan <- output } }() go func() { for err := range errorChan { session.ErrorChan <- err } }() handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteInstall(ctx, cr, methodID, uiType, isUpdate) if err != nil { session.ErrorChan <- err } else if !isUpdate { // Only save to config for new installations, not updates appName := installer.GetMethodDisplayName(methodID) appType := constants.MethodAppType[methodID] installPath := getInstallPathForMethod(methodID, p, config.GetInstallPath()) // Only save version for portable/binary apps we manage // Package manager apps (flatpak, brew, apt) are checked at startup appVersion := "" if appType == "app" { appVersion = getAppVersionForMethod(methodID, inst, p) } app := config.InstalledApp{ AppName: appName, AppType: appType, InstallMethod: methodID, InstallPath: installPath, InstalledDate: time.Now().Format("2006-01-02 15:04:05"), AppVersion: appVersion, UIType: uiType, // Save UI type for MPV apps Managed: true, // Installed and managed by MPV Manager } if err := config.AddInstalledApp(app); err != nil { log.Error(constants.LogPrefixAPI + "Failed to save installed app: " + err.Error()) } else { log.Info(constants.LogPrefixAPI + "Successfully saved installed app: " + appName) // Update version cache for package manager apps (Flatpak, Brew) // This ensures version displays immediately without needing to restart the app if isPackageManagerApp(methodID) { versionInfo := GetPackageVersion(methodID, appName) typeTag := getTypeTag(methodID) s.versionCacheMux.Lock() if s.versionCache.PackageVersionMap == nil { s.versionCache.PackageVersionMap = make(map[string]AppVersionInfo) } s.versionCache.PackageVersionMap[methodID] = AppVersionInfo{ CurrentVersion: versionInfo, AvailableVersion: "Check via " + typeTag, } s.versionCacheMux.Unlock() log.Info(constants.LogPrefixAPI + "Updated version cache for " + methodID + ": " + versionInfo) } } } close(session.DoneChan) } func getInstallPathForMethod(methodID string, p *platform.Platform, defaultPath string) string { // Windows methods if methodID == constants.MethodMPVBinary || methodID == constants.MethodMPVBinaryV3 { return defaultPath } if methodID == constants.MethodMPCQT { return defaultPath } // Flatpak if methodID == constants.MethodMPVFlatpak || methodID == constants.MethodCelluloidFlatpak { return "/var/lib/flatpak/app" } // Package manager if methodID == constants.MethodMPVPackage || methodID == constants.MethodCelluloidPackage { if p != nil { switch p.DistroFamily { case "arch": return "pacman" case "debian": return "apt" case "rhel": return "dnf" } } return "package-manager" } // macOS if methodID == constants.MethodMPVApp { return "/Applications/MPV.app" } if methodID == constants.MethodIINA { return "/Applications/IINA.app" } if methodID == constants.MethodMPVBrew { return "brew" } return defaultPath } // extractVersionFromURL extracts version from a download URL // e.g., .../mpv-v0.41.0-macos-26-arm.zip -> 0.41.0 func extractVersionFromURL(url, prefix string) string { // Match prefix + optional 'v' or 'V' + version re := regexp.MustCompile(prefix + `[vV]?(\d+\.\d+\.\d+)`) matches := re.FindStringSubmatch(url) if len(matches) > 1 { return matches[1] } return "" } func getAppVersionForMethod(methodID string, inst *installer.Installer, p *platform.Platform) string { // Get version from release info for some methods switch methodID { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3: return inst.ReleaseInfo.MpvVersion case constants.MethodMPVApp: // For macOS MPV.app, extract version from download URL since MpvVersion may not be set if inst.ReleaseInfo.MpvVersion != "" { return inst.ReleaseInfo.MpvVersion } // Extract from URL based on architecture var url string if p.Arch == "arm64" { url = inst.ReleaseInfo.MacOS.ARMLatest.URL } else { url = inst.ReleaseInfo.MacOS.Intel15.URL } return extractVersionFromURL(url, "mpv-") case constants.MethodMPCQT: return inst.ReleaseInfo.MPCQT.AppVersion case constants.MethodIINA: return inst.ReleaseInfo.IINA.AppVersion } // Get version from package manager for Flatpak/Brew switch methodID { case constants.MethodMPVFlatpak: return inst.GetFlatpakVersion(constants.FlatpakMPV) case constants.MethodCelluloidFlatpak: return inst.GetFlatpakVersion(constants.FlatpakCelluloid) case constants.MethodMPVPackage, constants.MethodCelluloidPackage: packageName := constants.PackageMPV if methodID == constants.MethodCelluloidPackage { packageName = constants.PackageCelluloid } return inst.GetPackageVersion(packageName) case constants.MethodMPVBrew: return inst.GetBrewVersion(constants.PackageMPV) } return "" } func (s *Server) handleUninstallAPI(w http.ResponseWriter, r *http.Request) { var req UninstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode uninstall request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate app name if err := ValidateAppName(req.AppName); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app name: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Normalize and validate app type (legacy types like "flatpak" are normalized to "app") normalizedAppType := NormalizeAppType(req.AppType) if err := ValidateAppType(req.AppType); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app type: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Use normalized type req.AppType = normalizedAppType // Validate install method if err := ValidateMethodID(req.InstallMethod); err != nil { log.Error(constants.LogPrefixAPI + "Invalid install method: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } log.Info(constants.LogPrefixAPI + "Uninstall request received: app_name=" + req.AppName + ", app_type=" + req.AppType + ", install_method=" + req.InstallMethod) // Create session for uninstallation session := s.sessions.Create(req.InstallMethod + "-uninstall") // Run uninstallation in background go s.runUninstallation(session, req) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "session_id": session.ID, "status": "uninstall_started", }) } func (s *Server) runUninstallation(session *Session, req UninstallRequest) { p := s.platform inst := s.installer outputChan := make(chan string, 1000) errorChan := make(chan error, 10) // Create a context for this uninstallation (sessions don't support cancellation yet) ctx := context.Background() cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) // Start streaming output to session go func() { for output := range outputChan { session.OutputChan <- output } }() // Start streaming errors to session go func() { for err := range errorChan { session.ErrorChan <- err } }() app := config.InstalledApp{ AppName: req.AppName, AppType: req.AppType, InstallMethod: req.InstallMethod, } handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteUninstall(ctx, cr, &app) if err != nil { session.ErrorChan <- err } else { // Remove from installed apps config if err := config.RemoveInstalledApp(req.AppName); err != nil { log.Error(constants.LogPrefixAPI + "Failed to remove app from config: " + err.Error()) session.ErrorChan <- err } else { log.Info(constants.LogPrefixAPI + "Successfully uninstalled: " + req.AppName) } } close(session.DoneChan) } type UninstallRequest struct { AppName string `json:"app_name"` AppType string `json:"app_type"` InstallMethod string `json:"install_method"` } func (s *Server) handleFileAssociationsSetupAPI(w http.ResponseWriter, r *http.Request) { if !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "File associations only available on Windows") return } inst := s.installer 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": "File associations registered successfully", }) } func (s *Server) handleFileAssociationsRemoveAPI(w http.ResponseWriter, r *http.Request) { if !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "File associations only available on Windows") return } inst := s.installer 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 !s.platform.IsWindows() { sendJSONError(w, http.StatusMethodNotAllowed, "Shortcuts only available on Windows") return } inst := s.installer 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 := config.GetInstallPath() 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 := os.ReadFile(logPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(logContent) } func (s *Server) handleLogsClearAPI(w http.ResponseWriter, r *http.Request) { logPath := log.GetFilePath() if err := os.WriteFile(logPath, []byte(""), 0644); 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() logContent, err := os.ReadFile(logPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=mpv-manager.log") w.Write(logContent) } type LogError struct { Timestamp string `json:"timestamp"` Message string `json:"message"` Details string `json:"details"` } func (s *Server) handleRecentErrorsAPI(w http.ResponseWriter, r *http.Request) { logPath := log.GetFilePath() content, err := os.ReadFile(logPath) 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 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++ { if strings.TrimSpace(lines[j]) != "" { details += strings.TrimSpace(lines[j]) + "\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) handleConfigApplyAPI(w http.ResponseWriter, r *http.Request) { var req ConfigApplyRequest body, _ := io.ReadAll(r.Body) // Only try to decode JSON if body is not empty if len(body) > 0 { if err := json.Unmarshal(body, &req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode config apply request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } } // Validate audio language preference if req.AudioLanguage != "" { if err := ValidateLanguageCode(req.AudioLanguage); err != nil { log.Error(constants.LogPrefixAPI + "Invalid audio language code: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } config.SetConfigValue(constants.ConfigKeyAudioLanguage, []string{req.AudioLanguage}, true) } // Validate subtitle language preference if req.SubtitleLanguage != "" { if err := ValidateLanguageCode(req.SubtitleLanguage); err != nil { log.Error(constants.LogPrefixAPI + "Invalid subtitle language code: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } config.SetConfigValue(constants.ConfigKeySubtitleLanguage, []string{req.SubtitleLanguage}, true) } // Validate and handle hardware acceleration setting if req.HardwareAccel != "" { if err := ValidateHWAValue(req.HardwareAccel); err != nil { log.Error(constants.LogPrefixAPI + "Invalid hardware acceleration method: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Save to mpv.conf err := config.SetConfigValue(constants.ConfigKeyHardwareDecoder, []string{req.HardwareAccel}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply HWA setting: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } // Also save preference to mpv-manager.json for backup/restore if err := config.SetHWADecoderPreference(req.HardwareAccel); err != nil { log.Warn(constants.LogPrefixAPI + "Failed to save HWA preference: " + err.Error()) } } // Validate and handle video output driver setting if req.VideoOutputDriver != "" { if err := ValidateConfigValue(constants.ConfigKeyVideoOutput, req.VideoOutputDriver); err != nil { log.Error(constants.LogPrefixAPI + "Invalid video output driver: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyVideoOutput, []string{req.VideoOutputDriver}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply video output driver: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle video scale filter setting if req.VideoScaleFilter != "" { if err := ValidateConfigValue(constants.ConfigKeyScaleFilter, req.VideoScaleFilter); err != nil { log.Error(constants.LogPrefixAPI + "Invalid video scale filter: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScaleFilter, []string{req.VideoScaleFilter}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply video scale filter: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle dither algorithm setting if req.DitherAlgorithm != "" { if err := ValidateConfigValue(constants.ConfigKeyDitherAlgorithm, req.DitherAlgorithm); err != nil { log.Error(constants.LogPrefixAPI + "Invalid dither algorithm: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyDitherAlgorithm, []string{req.DitherAlgorithm}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply dither algorithm: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle save-position-on-quit setting if req.SavePositionOnQuit != "" { if err := ValidateConfigValue(constants.ConfigKeySavePositionOnQuit, req.SavePositionOnQuit); err != nil { log.Error(constants.LogPrefixAPI + "Invalid save-position-on-quit value: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeySavePositionOnQuit, []string{req.SavePositionOnQuit}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply save-position-on-quit setting: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle border setting if req.Border != "" { if err := ValidateConfigValue(constants.ConfigKeyBorder, req.Border); err != nil { log.Error(constants.LogPrefixAPI + "Invalid border value: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyBorder, []string{req.Border}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply border setting: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle profile setting if req.Profile != "" { if err := ValidateConfigValue(constants.ConfigKeyProfile, req.Profile); err != nil { log.Error(constants.LogPrefixAPI + "Invalid profile value: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyProfile, []string{req.Profile}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply profile setting: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot format if req.ScreenshotFormat != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotFormat, req.ScreenshotFormat); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot format: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotFormat, []string{req.ScreenshotFormat}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot format: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot tag colorspace if req.ScreenshotTagColorspace != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotTagColorspace, req.ScreenshotTagColorspace); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot tag colorspace: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotTagColorspace, []string{req.ScreenshotTagColorspace}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot tag colorspace: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot high bit depth if req.ScreenshotHighBitDepth != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotHighBitDepth, req.ScreenshotHighBitDepth); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot high bit depth: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotHighBitDepth, []string{req.ScreenshotHighBitDepth}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot high bit depth: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot template if req.ScreenshotTemplate != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotTemplate, req.ScreenshotTemplate); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot template: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotTemplate, []string{req.ScreenshotTemplate}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot template: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot directory if req.ScreenshotDirectory != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotDirectory, req.ScreenshotDirectory); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot directory: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotDirectory, []string{req.ScreenshotDirectory}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot directory: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot jpeg quality if req.ScreenshotJpegQuality != "" { if err := ValidateScreenshotQuality(req.ScreenshotJpegQuality); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot jpeg quality: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotJpegQuality, []string{req.ScreenshotJpegQuality}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot jpeg quality: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot png compression if req.ScreenshotPngCompression != "" { if err := ValidateScreenshotCompression(req.ScreenshotPngCompression); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot png compression: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotPngCompression, []string{req.ScreenshotPngCompression}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot png compression: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot webp lossless if req.ScreenshotWebpLossless != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotWebpLossless, req.ScreenshotWebpLossless); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot webp lossless: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotWebpLossless, []string{req.ScreenshotWebpLossless}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot webp lossless: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot webp quality if req.ScreenshotWebpQuality != "" { if err := ValidateScreenshotQuality(req.ScreenshotWebpQuality); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot webp quality: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotWebpQuality, []string{req.ScreenshotWebpQuality}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot webp quality: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot jxl distance if req.ScreenshotJxlDistance != "" { if err := ValidateScreenshotJxlDistance(req.ScreenshotJxlDistance); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot jxl distance: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotJxlDistance, []string{req.ScreenshotJxlDistance}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot jxl distance: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot avif encoder if req.ScreenshotAvifEncoder != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotAvifEncoder, req.ScreenshotAvifEncoder); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot avif encoder: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotAvifEncoder, []string{req.ScreenshotAvifEncoder}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot avif encoder: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot avif pixfmt if req.ScreenshotAvifPixfmt != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotAvifPixfmt, req.ScreenshotAvifPixfmt); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot avif pixfmt: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotAvifPixfmt, []string{req.ScreenshotAvifPixfmt}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot avif pixfmt: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } // Validate and handle screenshot avif opts if req.ScreenshotAvifOpts != "" { if err := ValidateConfigValue(constants.ConfigKeyScreenshotAvifOpts, req.ScreenshotAvifOpts); err != nil { log.Error(constants.LogPrefixAPI + "Invalid screenshot avif opts: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } err := config.SetConfigValue(constants.ConfigKeyScreenshotAvifOpts, []string{req.ScreenshotAvifOpts}, true) if err != nil { log.Error(constants.LogPrefixAPI + "Failed to apply screenshot avif opts: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", }) } // getMPVConfigValue reads a setting value from mpv.conf func getMPVConfigValue(key string) string { values := config.GetConfigValue(key) if len(values) == 0 { return "" } return values[0] } type ConfigApplyRequest struct { AudioLanguage string `json:"audio_language"` SubtitleLanguage string `json:"subtitle_language"` HardwareAccel string `json:"hardware_acceleration"` VideoOutputDriver string `json:"video_output_driver"` VideoScaleFilter string `json:"video_scale_filter"` DitherAlgorithm string `json:"dither_algorithm"` SavePositionOnQuit string `json:"save_position_on_quit"` Border string `json:"border"` Profile string `json:"profile"` // Screenshot settings (NEW) ScreenshotFormat string `json:"screenshot_format"` ScreenshotTagColorspace string `json:"screenshot_tag_colorspace"` ScreenshotHighBitDepth string `json:"screenshot_high_bit_depth"` ScreenshotTemplate string `json:"screenshot_template"` ScreenshotDirectory string `json:"screenshot_directory"` ScreenshotJpegQuality string `json:"screenshot_jpeg_quality"` ScreenshotPngCompression string `json:"screenshot_png_compression"` ScreenshotWebpLossless string `json:"screenshot_webp_lossless"` ScreenshotWebpQuality string `json:"screenshot_webp_quality"` ScreenshotJxlDistance string `json:"screenshot_jxl_distance"` ScreenshotAvifEncoder string `json:"screenshot_avif_encoder"` ScreenshotAvifPixfmt string `json:"screenshot_avif_pixfmt"` ScreenshotAvifOpts string `json:"screenshot_avif_opts"` } func (s *Server) handleConfigResetAPI(w http.ResponseWriter, r *http.Request) { outputChan := make(chan string, 1000) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err := s.installer.InstallMPVConfigWithOutput(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", }) } func (s *Server) handleConfigRestoreAPI(w http.ResponseWriter, r *http.Request) { var req ConfigRestoreRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate backup path if err := config.ValidateBackupPath(req.BackupPath); err != nil { log.Error(constants.LogPrefixAPI + "Invalid backup path: " + err.Error()) sendJSONError(w, http.StatusBadRequest, "Invalid backup path") return } outputChan := make(chan string, 1000) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err := s.installer.RestoreConfigWithOutput(cr, req.BackupPath) 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", }) } func (s *Server) handleConfigBackupDeleteAPI(w http.ResponseWriter, r *http.Request) { var req ConfigBackupDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate backup path if err := config.ValidateBackupPath(req.BackupPath); err != nil { log.Error(constants.LogPrefixAPI + "Invalid backup path: " + err.Error()) sendJSONError(w, http.StatusBadRequest, "Invalid backup path") return } // Delete the backup file if err := os.Remove(req.BackupPath); err != nil { log.Error(constants.LogPrefixAPI + "Failed to delete backup: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } log.Info(constants.LogPrefixAPI + "Successfully deleted backup: " + req.BackupPath) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", }) } type ConfigRestoreRequest struct { BackupPath string `json:"backup_path"` } type ConfigBackupDeleteRequest struct { BackupPath string `json:"backup_path"` } func (s *Server) handleCheckAppUpdatesAPI(w http.ResponseWriter, r *http.Request) { log.Info("[API] Checking for app updates...") // Refresh version check cache s.refreshVersionCache() // Get update items from cache s.versionCacheMux.RLock() var updateItems []version.UpdateItem for _, update := range s.versionCache.UpdateMap { updateItems = append(updateItems, update) } s.versionCacheMux.RUnlock() // Convert updates to client update info (this filters correctly) clientUpdates := s.convertClientUpdates(updateItems) data := struct { ClientUpdates []ClientUpdateInfo }{ ClientUpdates: clientUpdates, } w.Header().Set("Content-Type", constants.ContentTypeHTML) err := s.templates.ExecuteTemplate(w, "client-updates", data) if err != nil { log.Error("Failed to render client-updates template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) return } // Add artificial delay after response is written to give user time to see spinning animation log.Info("[API] Check updates complete, adding 1500ms delay for visual feedback...") time.Sleep(1500 * time.Millisecond) } // 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) { log.Info("[API] Refreshing installed apps, updates, and install methods...") // Refresh version check cache s.refreshVersionCache() // Get installed apps installedApps := config.GetInstalledApps() // Get update map from cache s.versionCacheMux.RLock() updateMap := make(map[string]version.UpdateItem) for methodID, update := range s.versionCache.UpdateMap { updateMap[methodID] = update } var updateItems []version.UpdateItem for _, update := range s.versionCache.UpdateMap { updateItems = append(updateItems, update) } s.versionCacheMux.RUnlock() // Group apps with update info appGroups := s.groupInstalledAppsWithUpdates(installedApps, updateMap) // Convert updates to client update info clientUpdates := s.convertClientUpdates(updateItems) // 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 } log.Info("[API] Apps refresh complete, adding 1500ms delay for visual feedback...") time.Sleep(1500 * time.Millisecond) } func (s *Server) handleAppUpdateAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // For updates, use the provided UI type or default uiType := req.UIType if uiType == "" { uiType = constants.DefaultUIType } session := s.sessions.Create(req.MethodID + "-update") go s.runInstallation(session, req.MethodID, uiType, true) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "session_id": session.ID, "status": "update_started", }) } func (s *Server) handleManagerUpdateAPI(w http.ResponseWriter, r *http.Request) { 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 } check := version.CheckForUpdate() if !check.UpdateAvailable { log.Info("[API] No manager update available") sendJSONError(w, http.StatusBadRequest, "No update available") return } log.Info("[API] Update available: " + check.LatestVersion) executablePath, err := os.Executable() if err != nil { log.Error("[API] Failed to get executable path: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } log.Info("[API] Starting manager self-update...") err = version.UpdateSelfWithProgress(executablePath, func(written, total int64) { log.Info(fmt.Sprintf("Update progress: %d/%d bytes", written, total)) }) if err != nil { log.Error("[API] Manager update failed: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } log.Info("[API] Manager update completed successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "action": "show_modal", "modal_type": constants.ModalTypeUpdateManagerSuccess, "message": "Manager updated successfully. Please restart the application.", }) } func getConfigBackups() []ConfigBackup { backupDir, err := constants.GetMPVConfigBackupDirWithHome() if err != nil { return []ConfigBackup{} } var items []ConfigBackup if entries, err := os.ReadDir(backupDir); err == nil { for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { info, _ := entry.Info() items = append(items, ConfigBackup{ Filename: entry.Name(), CreatedDate: info.ModTime().Format("2006-01-02 15:04:05"), FilePath: filepath.Join(backupDir, entry.Name()), }) } } } return items } func getConfigFilePath() string { configPath, err := constants.GetMPVConfigPathWithHome() if err != nil { return "" } return configPath } 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 go func() { time.Sleep(500 * time.Millisecond) log.Info("Shutting down server") s.Shutdown() os.Exit(0) }() } func (s *Server) handleLanguagesLoadAPI(w http.ResponseWriter, r *http.Request) { audioLanguages := config.GetConfigValue(constants.ConfigKeyAudioLanguage) subtitleLanguages := config.GetConfigValue(constants.ConfigKeySubtitleLanguage) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "audio_languages": audioLanguages, "subtitle_languages": subtitleLanguages, }) } // saveLanguagePreferencesWithRetry saves audio and subtitle language preferences to mpv.conf // with retry logic and exponential backoff (3 attempts: 1s, 2s, 3s delays) // Returns accumulated errors if all retries fail func saveLanguagePreferencesWithRetry(audioLanguages, subtitleLanguages []string) error { var errors []string maxRetries := 3 delays := []time.Duration{1 * time.Second, 2 * time.Second, 3 * time.Second} for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { log.Info(constants.LogPrefixAPI + fmt.Sprintf("Retrying language preferences save (attempt %d/%d)...", attempt+1, maxRetries)) time.Sleep(delays[attempt-1]) } var err error errors = []string{} // Save audio language if err = config.SetConfigValue(constants.ConfigKeyAudioLanguage, audioLanguages, true); err != nil { errors = append(errors, fmt.Sprintf("audio: %v", err)) log.Warn(constants.LogPrefixAPI + fmt.Sprintf("Failed to save audio language (attempt %d): %v", attempt+1, err)) } else { if len(audioLanguages) > 0 { log.Info(constants.LogPrefixAPI + "Audio language written to mpv.conf: " + strings.Join(audioLanguages, ",")) } else { log.Info(constants.LogPrefixAPI + "Audio language cleared from mpv.conf") } } // Save subtitle language if err = config.SetConfigValue(constants.ConfigKeySubtitleLanguage, subtitleLanguages, true); err != nil { errors = append(errors, fmt.Sprintf("subtitle: %v", err)) log.Warn(constants.LogPrefixAPI + fmt.Sprintf("Failed to save subtitle language (attempt %d): %v", attempt+1, err)) } else { if len(subtitleLanguages) > 0 { log.Info(constants.LogPrefixAPI + "Subtitle language written to mpv.conf: " + strings.Join(subtitleLanguages, ",")) } else { log.Info(constants.LogPrefixAPI + "Subtitle language cleared from mpv.conf") } } // If no errors, success if len(errors) == 0 { log.Info(constants.LogPrefixAPI + "Language preferences saved successfully") return nil } // If this is the last attempt, break and return accumulated errors if attempt == maxRetries-1 { break } } // Return accumulated errors return fmt.Errorf("failed after %d attempts: %s", maxRetries, strings.Join(errors, "; ")) } func (s *Server) handleLanguagesSaveAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } var req LanguagesSaveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode languages save request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } log.Info(constants.LogPrefixAPI + "Languages save request received: audio_languages=" + fmt.Sprintf("%v", req.AudioLanguages) + ", subtitle_languages=" + fmt.Sprintf("%v", req.SubtitleLanguages)) // Save language preferences with retry logic if err := saveLanguagePreferencesWithRetry(req.AudioLanguages, req.SubtitleLanguages); err != nil { log.Error(constants.LogPrefixAPI + "Failed to save language preferences after retries: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } log.Info(constants.LogPrefixAPI + "Language preferences saved successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", }) } type LanguagesSaveRequest struct { AudioLanguages []string `json:"audio_languages"` SubtitleLanguages []string `json:"subtitle_languages"` } // handleAllRegionsAPI returns all regions for regional variant search func (s *Server) handleAllRegionsAPI(w http.ResponseWriter, r *http.Request) { // Load locale data from assets locales, err := assets.ReadLocales() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to load locales: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } // Flatten all regions from all locales, adding parent language code var allRegions []ExtendedRegionalData for _, entry := range locales { for _, region := range entry.Regions { extendedRegion := ExtendedRegionalData{ Locale: region.Locale, CountryName: region.CountryName, CountryLocal: region.CountryLocal, CountryCode: region.CountryCode, Flag: region.Flag, MajorRegionalVariant: region.MajorRegionalVariant, Population: region.Population, LanguageCode: entry.LanguageCode, LanguageName: entry.LanguageName, LanguageLocal: entry.LanguageLocal, } allRegions = append(allRegions, extendedRegion) } } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "locales": allRegions, }) } // handleMajorLanguagesAPI returns list of major languages for step 1 selection func (s *Server) handleMajorLanguagesAPI(w http.ResponseWriter, r *http.Request) { // Load locale data from assets locales, err := assets.ReadLocales() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to load locales: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } // Get major languages (one entry per language code, no regional variants) majorEntries := locale.GetMajorLanguages(locales) // Convert to MajorLanguageData format majorLanguages := make([]MajorLanguageData, len(majorEntries)) for i := range majorEntries { majorLanguages[i] = MajorLanguageData{ LanguageCode: majorEntries[i].LanguageCode, LanguageName: majorEntries[i].LanguageName, LanguageLocal: majorEntries[i].LanguageLocal, Flag: locale.GetFlagForLanguageCode(majorEntries[i].LanguageCode), } } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "major_languages": majorLanguages, }) } // handleRegionalVariantsAPI returns regional variants for a specific major language func (s *Server) handleRegionalVariantsAPI(w http.ResponseWriter, r *http.Request) { majorCode := r.URL.Query().Get(constants.QueryParamMethodID) if majorCode == "" { sendJSONError(w, http.StatusBadRequest, "Missing major language code parameter") return } // Load locale data from assets locales, err := assets.ReadLocales() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to load locales: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } // Find and merge all entries with matching language code // (handles cases like "Spanish" and "Spanish (Latin America)" both having code "es") majorEntry := locale.FindAndMergeByLanguageCode(majorCode, locales) if majorEntry == nil { sendJSONError(w, http.StatusNotFound, "Major language not found: "+majorCode) return } // Get regional variants with prioritization regionalVariants := locale.GetMajorRegionalVariants(majorEntry) // Convert to RegionalVariantData format variantData := make([]RegionalVariantData, len(regionalVariants)) for i := range regionalVariants { variantData[i] = RegionalVariantData{ Locale: regionalVariants[i].Locale, CountryName: regionalVariants[i].CountryName, CountryLocal: regionalVariants[i].CountryLocal, CountryCode: regionalVariants[i].CountryCode, Flag: regionalVariants[i].Flag, MajorRegionalVariant: regionalVariants[i].MajorRegionalVariant, Population: regionalVariants[i].Population, } } // Get flag for major language majorLanguageFlag := locale.GetFlagForLanguageCode(majorCode) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "regional_variants": variantData, "major_language": map[string]interface{}{ "language_code": majorEntry.LanguageCode, "language_name": majorEntry.LanguageName, "language_local": majorEntry.LanguageLocal, "flag": majorLanguageFlag, }, }) } // Helper function to extract flag from locale string func getFlagFromLocale(localeStr string) string { // Try to get flag for locale _, region := locale.FindByLocale(localeStr, []locale.LocaleEntry{}) if region != nil && region.Flag != "" { return region.Flag } // Fallback: extract language code and get flag parts := strings.Split(localeStr, "-") if len(parts) > 0 { langCode := parts[0] return locale.GetFlagForLanguageCode(langCode) } return "" } func (s *Server) handleModalAPI(w http.ResponseWriter, r *http.Request) { modalType := r.URL.Query().Get(constants.QueryParamType) if modalType == "" { sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageMissingModalType) return } var modalConfig ModalConfig switch modalType { case constants.ModalTypeShutdown: modalConfig = ModalConfig{ Title: "Close MPV.Rocks Installer", Message: "Are you sure you want to close the MPV.Rocks Installer? The server will shut down and this tab will attempt to close automatically. If it doesn't close, please close it manually.", ConfirmText: "Close App", CancelText: "Cancel", ConfirmURL: "/api/shutdown", Danger: true, } case constants.ModalTypeUpdateManager: latestVersion := r.URL.Query().Get(constants.QueryParamVersion) if latestVersion == "" { latestVersion = "latest" } modalConfig = ModalConfig{ Title: "Update MPV Manager", Message: fmt.Sprintf("Update MPV Manager to version %s?", latestVersion), ConfirmText: "Update Now", CancelText: "Cancel", ConfirmURL: "/api/manager/update", Danger: false, } case constants.ModalTypeUpdateManagerSuccess: modalConfig = ModalConfig{ Title: "Update Successful!", Message: `
MPV Manager has been updated successfully. Please close and relaunch the application to use the new version.
`, ConfirmText: "Close & Exit", CancelText: "", ConfirmURL: "/api/shutdown", Danger: false, Success: true, } case constants.ModalTypeUninstall: appName := r.URL.Query().Get(constants.QueryParamAppName) appType := r.URL.Query().Get(constants.QueryParamAppType) installMethod := r.URL.Query().Get(constants.QueryParamInstallMethod) if appName == "" { sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageMissingAppName) return } modalConfig = ModalConfig{ Title: "Uninstall App", Message: fmt.Sprintf("Are you sure you want to uninstall %s?", appName), ConfirmText: "Uninstall", CancelText: "Cancel", ConfirmURL: "/api/uninstall", ConfirmData: fmt.Sprintf(`{"app_name": "%s", "app_type": "%s", "install_method": "%s"}`, appName, appType, installMethod), Danger: true, } case constants.ModalTypeRemoveFileAssociations: modalConfig = ModalConfig{ Title: "Remove File Associations", Message: "Are you sure you want to remove file associations?", ConfirmText: "Remove", CancelText: "Cancel", ConfirmURL: "/api/file-associations/remove", Danger: true, } case constants.ModalTypeResetConfig: modalConfig = ModalConfig{ Title: "Reset MPV Config", Message: "This will reset your config to recommended settings. A backup will be created. Continue?", ConfirmText: "Reset Config", CancelText: "Cancel", ConfirmURL: "/api/config/reset", Danger: true, } case constants.ModalTypeRestoreConfig: backupPath := r.URL.Query().Get(constants.QueryParamBackupPath) if backupPath == "" { sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageMissingBackupPath) return } // Validate backup path if err := config.ValidateBackupPath(backupPath); err != nil { log.Error(constants.LogPrefixAPI + " Invalid backup path: " + err.Error()) sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageInvalidBackupPath) return } modalConfig = ModalConfig{ Title: "Restore Config Backup", Message: "Are you sure you want to restore this configuration backup?", ConfirmText: "Restore", CancelText: "Cancel", ConfirmURL: "/api/config/restore", ConfirmData: fmt.Sprintf(`{"backup_path": "%s"}`, backupPath), Danger: true, // HTMX partial refresh configuration HTMXRefresh: "/api/config/backups/list", HTMXTarget: "#backup-list-container", } case constants.ModalTypeApplyConfig: modalConfig = ModalConfig{ Title: "Apply Configuration Settings", Message: "Apply the selected configuration settings?", ConfirmText: "Apply Settings", CancelText: "Cancel", ConfirmURL: "/api/config/apply", Danger: false, } case constants.ModalTypeDeleteBackup: backupPath := r.URL.Query().Get(constants.QueryParamBackupPath) if backupPath == "" { sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageMissingBackupPath) return } // Validate backup path if err := config.ValidateBackupPath(backupPath); err != nil { log.Error(constants.LogPrefixAPI + " Invalid backup path: " + err.Error()) sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageInvalidBackupPath) return } modalConfig = ModalConfig{ Title: "Delete Backup", Message: "Are you sure you want to delete this configuration backup?", ConfirmText: "Delete", CancelText: "Cancel", ConfirmURL: "/api/config/backup/delete", ConfirmData: fmt.Sprintf(`{"backup_path": "%s"}`, backupPath), Danger: true, // HTMX partial refresh configuration HTMXRefresh: "/api/config/backups/list", HTMXTarget: "#backup-list-container", } case constants.ModalTypeClearLogs: modalConfig = ModalConfig{ Title: "Clear Application Log", Message: "Are you sure you want to clear the application log file? This action cannot be undone.", ConfirmText: "Clear Log", CancelText: "Cancel", ConfirmURL: "/api/logs/clear", Danger: true, // HTMX partial refresh configuration HTMXRefresh: "/api/logs", HTMXTarget: "#log-content", } default: sendJSONError(w, http.StatusBadRequest, constants.ResponseMessageUnknownModalType) return } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(modalConfig) } // ============================================================================ // Job API Endpoints (New Job Manager System) // ============================================================================ // handleJobRoutes routes /api/jobs/{id} to the appropriate handler based on method func (s *Server) handleJobRoutes(w http.ResponseWriter, r *http.Request) { // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if path == "/api/jobs/" || path == "/api/jobs" { // No job ID provided sendJSONError(w, http.StatusBadRequest, "Job ID required") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" || strings.Contains(jobID, "/") { sendJSONError(w, http.StatusBadRequest, "Invalid job ID") return } switch r.Method { case http.MethodGet: s.handleJobGetAPI(w, r) case http.MethodDelete: s.handleJobCancelAPI(w, r) default: sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") } } // handleJobsListAPI returns all active and recent jobs func (s *Server) handleJobsListAPI(w http.ResponseWriter, r *http.Request) { activeJobs := s.jobManager.GetActiveJobs() recentJobs := s.jobManager.GetRecentJobs() // Format active jobs active := make([]map[string]interface{}, 0, len(activeJobs)) for _, job := range activeJobs { active = append(active, jobToAPIResponse(job)) } // Format recent jobs recent := make([]map[string]interface{}, 0, len(recentJobs)) for _, job := range recentJobs { recent = append(recent, jobToAPIResponse(job)) } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "active": active, "recent": recent, "total": len(active) + len(recent), }) } // handleJobGetAPI returns details for a specific job func (s *Server) handleJobGetAPI(w http.ResponseWriter, r *http.Request) { // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if !strings.HasPrefix(path, prefix) { sendJSONError(w, http.StatusBadRequest, "Invalid path") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" { sendJSONError(w, http.StatusBadRequest, "Job ID required") return } job, exists := s.jobManager.GetJob(jobID) if !exists { // Also check recent jobs recentJobs := s.jobManager.GetRecentJobs() for _, rj := range recentJobs { if rj.ID == jobID { job = rj exists = true break } } } if !exists { sendJSONError(w, http.StatusNotFound, "Job not found") return } response := jobToAPIResponse(job) response["output"] = job.Output // Include full output for individual job w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(response) } // handleJobCancelAPI cancels (or removes) a job func (s *Server) handleJobCancelAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if !strings.HasPrefix(path, prefix) { sendJSONError(w, http.StatusBadRequest, "Invalid path") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" { sendJSONError(w, http.StatusBadRequest, "Job ID required") return } // Use the new CancelJob method which properly handles context cancellation err := s.jobManager.CancelJob(jobID) if err != nil { // Check if it's a "not found" error if strings.Contains(err.Error(), "not found") { sendJSONError(w, http.StatusNotFound, err.Error()) return } // Other errors (already completed, etc.) sendJSONError(w, http.StatusBadRequest, err.Error()) return } log.Info(constants.LogPrefixAPI + "Job cancelled successfully: " + jobID) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "cancelled", "job_id": jobID, "message": "Job cancelled successfully", }) } // jobToAPIResponse converts a Job to an API response map func jobToAPIResponse(job *Job) map[string]interface{} { response := map[string]interface{}{ "id": job.ID, "type": job.Type, "methodId": job.MethodID, "appName": job.AppName, "status": job.Status, "progress": job.Progress, "message": job.Message, "startedAt": job.StartedAt.Format(time.RFC3339), "durationSecs": job.Duration().Seconds(), } if job.Error != "" { response["error"] = job.Error } if job.ErrorDetails != "" { response["errorDetails"] = job.ErrorDetails } if job.CompletedAt != nil { response["completedAt"] = job.CompletedAt.Format(time.RFC3339) } return response } // ============================================================================ // Job-Based Installation (New System) // ============================================================================ // handleInstallJobAPI creates a new job for installation (new job-based API) // This is the new endpoint that uses JobManager instead of sessions func (s *Server) handleInstallJobAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode install request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Check if there's already an active job for this method if s.jobManager.HasActiveJob(req.MethodID) { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.MethodID) sendJSONError(w, http.StatusConflict, "An installation is already in progress for this app") return } log.Info(constants.LogPrefixAPI + "Install job request received: method_id=" + req.MethodID + ", ui_type=" + req.UIType) // Determine UI type, with backwards compatibility for install_uosc uiType := req.UIType if uiType == "" { if req.InstallUOSC { uiType = constants.UITypeUOSC } else { uiType = constants.DefaultUIType } } // Get app name for the job appName := installer.GetMethodDisplayName(req.MethodID) // Create a new job job := s.jobManager.CreateJob("install", req.MethodID, appName) // Run the installation in a goroutine using the job system go s.runInstallationJob(job.ID, req.MethodID, uiType, false) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // handleAppUpdateJobAPI creates a new job for app update (new job-based API) func (s *Server) handleAppUpdateJobAPI(w http.ResponseWriter, r *http.Request) { var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID if err := ValidateMethodID(req.MethodID); err != nil { log.Error(constants.LogPrefixAPI + "Invalid method ID: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate method ID is compatible with current platform if err := ValidateInstallMethod(req.MethodID, s.platform.IsWindows(), s.platform.IsDarwin(), s.platform.IsLinux()); err != nil { log.Error(constants.LogPrefixAPI + "Method not available on this platform: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Check if there's already an active job for this method if s.jobManager.HasActiveJob(req.MethodID) { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.MethodID) sendJSONError(w, http.StatusConflict, "An update is already in progress for this app") return } appName := installer.GetMethodDisplayName(req.MethodID) // For updates, use the provided UI type or default uiType := req.UIType if uiType == "" { uiType = constants.DefaultUIType } // Create a new job job := s.jobManager.CreateJob("update", req.MethodID, appName) // Run the update in a goroutine go s.runInstallationJob(job.ID, req.MethodID, uiType, true) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // handleUninstallJobAPI creates a new job for app uninstallation (new job-based API) func (s *Server) handleUninstallJobAPI(w http.ResponseWriter, r *http.Request) { var req UninstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error(constants.LogPrefixAPI + "Failed to decode uninstall request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate app name if err := ValidateAppName(req.AppName); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app name: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Normalize and validate app type normalizedAppType := NormalizeAppType(req.AppType) if err := ValidateAppType(req.AppType); err != nil { log.Error(constants.LogPrefixAPI + "Invalid app type: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } req.AppType = normalizedAppType // Validate install method if err := ValidateMethodID(req.InstallMethod); err != nil { log.Error(constants.LogPrefixAPI + "Invalid install method: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } // Check if there's already an active job for this method if s.jobManager.HasActiveJob(req.InstallMethod) { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + req.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } log.Info(constants.LogPrefixAPI + "Uninstall job request received: app_name=" + req.AppName + ", install_method=" + req.InstallMethod) // Create a new job job := s.jobManager.CreateJob("uninstall", req.InstallMethod, req.AppName) // Run the uninstallation in a goroutine go s.runUninstallationJob(job.ID, req) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runUninstallationJob executes an uninstallation using the job manager system func (s *Server) runUninstallationJob(jobID string, req UninstallRequest) { // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } p := s.platform inst := s.installer ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting uninstallation...") outputChan := make(chan string, 1000) errorChan := make(chan error, 10) cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) // Goroutine to process output lines go func() { for output := range outputChan { s.jobManager.AddOutput(jobID, output) } }() // Goroutine to process errors go func() { for err := range errorChan { s.jobManager.AddOutput(jobID, "ERROR: "+err.Error()) } }() // Check for cancellation before proceeding if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("uninstallation cancelled"), "Job was cancelled before it could start") return } // Update progress s.jobManager.UpdateProgress(jobID, 10, "Executing uninstallation commands...") app := config.InstalledApp{ AppName: req.AppName, AppType: req.AppType, InstallMethod: req.InstallMethod, } handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteUninstall(ctx, cr, &app) // Check if cancelled during execution if ctx.Err() != nil { s.jobManager.SetError(jobID, fmt.Errorf("uninstallation cancelled"), "Job was cancelled during execution") log.Error(constants.LogPrefixAPI + "Uninstallation job cancelled: " + jobID) return } if err != nil { errorDetails := "" if strings.Contains(err.Error(), "exit status") { errorDetails = "Command execution failed. Check the output for details." } else if strings.Contains(err.Error(), "permission denied") { errorDetails = "Permission denied. Try running with elevated privileges." } s.jobManager.SetError(jobID, err, errorDetails) log.Error(constants.LogPrefixAPI + "Uninstallation job failed: " + err.Error()) return } // Remove from installed apps config if err := config.RemoveInstalledApp(req.AppName); err != nil { log.Error(constants.LogPrefixAPI + "Failed to remove app from config: " + err.Error()) s.jobManager.AddOutput(jobID, "Warning: Failed to remove app from configuration") } else { log.Info(constants.LogPrefixAPI + "Successfully uninstalled: " + req.AppName) s.jobManager.AddOutput(jobID, "App removed from configuration successfully") } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "Uninstallation completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "Uninstallation job completed: " + jobID) } // runInstallationJob executes an installation using the job manager system func (s *Server) runInstallationJob(jobID, methodID string, uiType string, isUpdate bool) { // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } p := s.platform inst := s.installer ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting installation...") outputChan := make(chan string, 1000) errorChan := make(chan error, 10) cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) // Goroutine to process output lines go func() { for output := range outputChan { // Add output to job (this will also try to parse progress) s.jobManager.AddOutput(jobID, output) } }() // Goroutine to process errors go func() { for err := range errorChan { s.jobManager.AddOutput(jobID, "ERROR: "+err.Error()) } }() // Check for cancellation before proceeding if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("installation cancelled"), "Job was cancelled before it could start") return } // Update progress s.jobManager.UpdateProgress(jobID, 10, "Executing installation commands...") handler := installer.NewInstallationHandler(p, inst) err := handler.ExecuteInstall(ctx, cr, methodID, uiType, isUpdate) // Check if cancelled during execution if ctx.Err() != nil { s.jobManager.SetError(jobID, fmt.Errorf("installation cancelled"), "Job was cancelled during execution") log.Error(constants.LogPrefixAPI + "Installation job cancelled: " + jobID) return } if err != nil { // Extract error details for more context errorDetails := "" if strings.Contains(err.Error(), "exit status") { errorDetails = "Command execution failed. Check the output for details." } else if strings.Contains(err.Error(), "permission denied") { errorDetails = "Permission denied. Try running with elevated privileges." } else if strings.Contains(err.Error(), "network") || strings.Contains(err.Error(), "connection") { errorDetails = "Network error. Check your internet connection." } s.jobManager.SetError(jobID, err, errorDetails) log.Error(constants.LogPrefixAPI + "Installation job failed: " + err.Error()) return } // Success path if !isUpdate { // Only save to config for new installations, not updates appName := installer.GetMethodDisplayName(methodID) appType := constants.MethodAppType[methodID] installPath := getInstallPathForMethod(methodID, p, config.GetInstallPath()) // Only save version for portable/binary apps we manage // Package manager apps (flatpak, brew, apt) are checked at startup appVersion := "" if appType == "app" { appVersion = getAppVersionForMethod(methodID, inst, p) } app := config.InstalledApp{ AppName: appName, AppType: appType, InstallMethod: methodID, InstallPath: installPath, InstalledDate: time.Now().Format("2006-01-02 15:04:05"), AppVersion: appVersion, UIType: uiType, // Save UI type for MPV apps Managed: true, // Installed and managed by MPV Manager } if err := config.AddInstalledApp(app); err != nil { log.Error(constants.LogPrefixAPI + "Failed to save installed app: " + err.Error()) s.jobManager.AddOutput(jobID, "Warning: Failed to save app to configuration") } else { log.Info(constants.LogPrefixAPI + "Successfully saved installed app: " + appName) s.jobManager.AddOutput(jobID, "App saved to configuration successfully") // Update version cache for package manager apps if isPackageManagerApp(methodID) { versionInfo := GetPackageVersion(methodID, appName) typeTag := getTypeTag(methodID) s.versionCacheMux.Lock() if s.versionCache.PackageVersionMap == nil { s.versionCache.PackageVersionMap = make(map[string]AppVersionInfo) } s.versionCache.PackageVersionMap[methodID] = AppVersionInfo{ CurrentVersion: versionInfo, AvailableVersion: "Check via " + typeTag, } s.versionCacheMux.Unlock() log.Info(constants.LogPrefixAPI + "Updated version cache for " + methodID + ": " + versionInfo) } } } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "Installation completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "Installation job completed: " + jobID) } // ============================================================================ // UI Selection API Endpoints // ============================================================================ // UIChangeRequest for the change UI API type UIChangeRequest struct { AppName string `json:"app_name"` UIType string `json:"ui_type"` } // handleUIOptionsAPI returns UI options for the selection modal // GET /api/ui/options?method_id=mpv-binary&app_name=MPV func (s *Server) handleUIOptionsAPI(w http.ResponseWriter, r *http.Request) { methodID := r.URL.Query().Get(constants.QueryParamMethodID) appName := r.URL.Query().Get(constants.QueryParamAppName) // Check if this method needs UI selection (MPV-based apps only) needsUI := methodNeedsUISelection(methodID) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "needs_ui_selection": needsUI, "method_id": methodID, "app_name": appName, "options": []map[string]interface{}{ { "id": constants.UITypeUOSC, "name": "UOSC", "description": "Feature-rich modern UI with touch support", "recommended": true, }, { "id": constants.UITypeModernZ, "name": "ModernZ", "description": "Classic OSC look with modern features", "recommended": false, }, { "id": constants.UITypeNone, "name": "No UI Overlay", "description": "Use MPV's default interface", "recommended": false, }, }, }) } // methodNeedsUISelection returns true if the installation method needs UI selection // Only MPV-based methods need UI selection; IINA, MPC-QT, Celluloid, Infuse have their own UIs func methodNeedsUISelection(methodID string) bool { mpvMethods := []string{ constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVFlatpak, constants.MethodMPVPackage, constants.MethodMPVBrew, constants.MethodMPVApp, } for _, m := range mpvMethods { if methodID == m { return true } } return false } // handleUIChangeAPI handles requests to change the UI for an installed app // POST /api/ui/change func (s *Server) handleUIChangeAPI(w http.ResponseWriter, r *http.Request) { var req UIChangeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Validate app name if req.AppName == "" { sendJSONError(w, http.StatusBadRequest, "Missing app_name") return } // Validate UI type if req.UIType != constants.UITypeUOSC && req.UIType != constants.UITypeModernZ && req.UIType != constants.UITypeNone { sendJSONError(w, http.StatusBadRequest, "Invalid UI type. Must be 'uosc', 'modernz', or 'none'") return } // Find the installed app apps := config.GetInstalledApps() var foundApp *config.InstalledApp for i := range apps { if apps[i].AppName == req.AppName { foundApp = &apps[i] break } } if foundApp == nil { sendJSONError(w, http.StatusNotFound, "App not found: "+req.AppName) return } // Check if this method supports UI selection if !methodNeedsUISelection(foundApp.InstallMethod) { sendJSONError(w, http.StatusBadRequest, "This app does not support UI customization") return } log.Info(constants.LogPrefixAPI + "UI change request received: app_name=" + req.AppName + ", ui_type=" + req.UIType) // Check if there's already an active job for this app if s.jobManager.HasActiveJob(foundApp.InstallMethod) { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + foundApp.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } // Create a new job for UI change job := s.jobManager.CreateJob("ui-change", foundApp.InstallMethod, req.AppName) // Run the UI change in a goroutine go s.runUIChangeJob(job.ID, foundApp, req.UIType) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runUIChangeJob executes a UI change operation using the job manager system func (s *Server) runUIChangeJob(jobID string, app *config.InstalledApp, newUIType string) { // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting UI change...") // Create output channels outputChan := make(chan string, 100) errorChan := make(chan error, 10) cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) // Goroutine to process output lines go func() { for output := range outputChan { s.jobManager.AddOutput(jobID, output) } }() // Goroutine to process errors go func() { for err := range errorChan { s.jobManager.AddOutput(jobID, "ERROR: "+err.Error()) } }() // Check for cancellation before proceeding if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled before it could start") return } // Determine old UI type (default to UOSC if not set) oldUIType := app.UIType if oldUIType == "" { oldUIType = constants.UITypeUOSC } // Get config directory for this app configDir := getConfigDirForApp(app) s.jobManager.UpdateProgress(jobID, 10, fmt.Sprintf("Changing UI from %s to %s...", oldUIType, newUIType)) s.jobManager.AddOutput(jobID, fmt.Sprintf("Current UI: %s", oldUIType)) s.jobManager.AddOutput(jobID, fmt.Sprintf("Target UI: %s", newUIType)) // Check for cancellation if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled during execution") return } // Perform UI change if different from current type if oldUIType != newUIType { s.jobManager.UpdateProgress(jobID, 20, "Removing old UI files...") // Check for cancellation if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("UI change cancelled"), "Job was cancelled during execution") return } // Install new UI (InstallUISafely handles removing old files first) s.jobManager.UpdateProgress(jobID, 40, "Installing new UI...") installer.InstallUISafely(s.installer, cr, configDir, newUIType) s.jobManager.AddOutput(jobID, "New UI installed") } else { s.jobManager.AddOutput(jobID, "UI type unchanged, nothing to do") } // Check for errors that may have occurred during installation select { case err := <-errorChan: s.jobManager.SetError(jobID, err, "Failed to install UI") log.Error(constants.LogPrefixAPI + "UI change job failed: " + err.Error()) return default: // No errors } // Update app's UI type in config uiTypeToSave := newUIType if newUIType == constants.UITypeNone { uiTypeToSave = "" } if err := config.UpdateInstalledAppUIType(app.AppName, uiTypeToSave); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to update UI type in configuration: "+err.Error()) log.Error(constants.LogPrefixAPI + "Failed to update app UI type: " + err.Error()) } else { s.jobManager.AddOutput(jobID, "Configuration updated successfully") } // Mark job as complete s.jobManager.UpdateProgress(jobID, 100, "UI change completed successfully") s.jobManager.CompleteJob(jobID) log.Info(constants.LogPrefixAPI + "UI change job completed: " + jobID) } // getConfigDirForApp returns the config directory path for an installed app func getConfigDirForApp(app *config.InstalledApp) string { homeDir, _ := os.UserHomeDir() switch app.InstallMethod { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3: // Windows portable installs use portable_config inside the install directory return filepath.Join(app.InstallPath, constants.PortableConfigDir) default: // macOS MPV.app, Flatpak, Package Manager, Brew all use ~/.config/mpv return filepath.Join(homeDir, ".config", "mpv") } } // ============================================================================ // Keyring API Endpoints (for sudo password storage) // ============================================================================ // handleKeyringAuthRoutes routes /api/keyring/auth based on HTTP method // POST -> store password, DELETE -> clear password func (s *Server) handleKeyringAuthRoutes(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: s.handleKeyringAuthStore(w, r) case http.MethodDelete: s.handleKeyringAuthClear(w, r) default: sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") } } // handleKeyringStatusAPI returns keyring status information // GET /api/keyring/status func (s *Server) handleKeyringStatusAPI(w http.ResponseWriter, r *http.Request) { status := keyring.DetectStatus() // Open keyring to check if password is stored kr, err := keyring.Open() hasPassword := false if err == nil { hasPassword = kr.HasPassword() } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "availableBackends": status.AvailableBackends, "activeBackend": status.ActiveBackend, "daemonRunning": status.DaemonRunning, "hasPassword": hasPassword, "installHint": status.InstallHint, "desktopEnv": status.DesktopEnv, "distro": status.Distro, "needsSetup": keyring.NeedsInstallPrompt(), "platform": status.Platform, }) } // handleKeyringAuthStore stores sudo password after validating it // POST /api/keyring/auth func (s *Server) handleKeyringAuthStore(w http.ResponseWriter, r *http.Request) { var req struct { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.Password == "" { w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Password is required", }) return } // On Linux, validate password with sudo before storing if runtime.GOOS == "linux" { cmd := exec.Command("sudo", "-S", "-v") cmd.Stdin = bytes.NewBufferString(req.Password + "\n") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() stdoutStr := stdout.String() stderrStr := stderr.String() if err != nil { log.Debug(fmt.Sprintf("Keyring auth validation failed: err=%v, stdout=%q, stderr=%q", err, stdoutStr, stderrStr)) log.Warn(constants.LogPrefixAPI + "Keyring auth: invalid password provided") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Invalid password", }) return } log.Debug(fmt.Sprintf("Keyring auth validation succeeded: stdout=%q, stderr=%q", stdoutStr, stderrStr)) } // Open keyring kr, err := keyring.Open() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to open keyring: " + err.Error()) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Failed to open keyring: " + err.Error(), }) return } // Store in keyring if err := kr.StorePassword(req.Password); err != nil { log.Error(constants.LogPrefixAPI + "Failed to store password: " + err.Error()) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Failed to store password: " + err.Error(), }) return } log.Info(constants.LogPrefixAPI + "Password stored in keyring successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) } // handleKeyringAuthClear clears stored password // DELETE /api/keyring/auth func (s *Server) handleKeyringAuthClear(w http.ResponseWriter, r *http.Request) { // Open keyring kr, err := keyring.Open() if err != nil { log.Error(constants.LogPrefixAPI + "Failed to open keyring: " + err.Error()) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": "Failed to open keyring: " + err.Error(), }) return } if err := kr.DeletePassword(); err != nil { log.Error(constants.LogPrefixAPI + "Failed to delete password: " + err.Error()) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, "error": err.Error(), }) return } log.Info(constants.LogPrefixAPI + "Password deleted from keyring successfully") w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) } // ============================================================================ // Settings API Endpoints // ============================================================================ // handleSettingsAPI returns current settings for the settings page // GET /api/settings 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, }) } // handleSettingsShortcutAPI toggles desktop shortcut preference // POST /api/settings/shortcut func (s *Server) handleSettingsShortcutAPI(w http.ResponseWriter, r *http.Request) { 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) { // 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", }) } // ============================================================================ // Adopt App API Endpoints // ============================================================================ // AdoptAppRequest represents a request to adopt an externally installed app type AdoptAppRequest struct { AppName string `json:"app_name"` InstallMethod string `json:"install_method"` UIType string `json:"ui_type"` } // handleAdoptAppAPI handles requests to adopt an externally installed app // POST /api/apps/adopt // This configures mpv.conf, optionally installs UI overlays, and marks the app as managed func (s *Server) handleAdoptAppAPI(w http.ResponseWriter, r *http.Request) { var req AdoptAppRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSONError(w, http.StatusBadRequest, "Invalid request body") return } // Validate app name if req.AppName == "" { sendJSONError(w, http.StatusBadRequest, "Missing app_name") return } // Validate install method if req.InstallMethod == "" { sendJSONError(w, http.StatusBadRequest, "Missing install_method") return } // Validate UI type (default to none if not provided) if req.UIType == "" { req.UIType = constants.UITypeNone } if req.UIType != constants.UITypeUOSC && req.UIType != constants.UITypeModernZ && req.UIType != constants.UITypeNone { sendJSONError(w, http.StatusBadRequest, "Invalid UI type. Must be 'uosc', 'modernz', or 'none'") return } // Find the installed app apps := config.GetInstalledApps() var foundApp *config.InstalledApp for i := range apps { if apps[i].AppName == req.AppName && apps[i].InstallMethod == req.InstallMethod { foundApp = &apps[i] break } } if foundApp == nil { sendJSONError(w, http.StatusNotFound, "App not found: "+req.AppName) return } // Check if already managed if foundApp.Managed { sendJSONError(w, http.StatusBadRequest, "App is already managed by MPV Manager") return } log.Info(constants.LogPrefixAPI + "Adopt app request received: app_name=" + req.AppName + ", method=" + req.InstallMethod + ", ui_type=" + req.UIType) // Check if there's already an active job for this app if s.jobManager.HasActiveJob(foundApp.InstallMethod) { log.Warn(constants.LogPrefixAPI + "Active job already exists for method: " + foundApp.InstallMethod) sendJSONError(w, http.StatusConflict, "An operation is already in progress for this app") return } // Create a new job for adoption job := s.jobManager.CreateJob("adopt", foundApp.InstallMethod, req.AppName) // Run the adoption in a goroutine go s.runAdoptAppJob(job.ID, foundApp, req.UIType) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "id": job.ID, "status": "started", }) } // runAdoptAppJob executes an app adoption operation using the job manager system func (s *Server) runAdoptAppJob(jobID string, app *config.InstalledApp, uiType string) { // Get the job to access its context job, exists := s.jobManager.GetJob(jobID) if !exists { log.Error(constants.LogPrefixAPI + "Job not found: " + jobID) return } ctx := job.Context() // Update progress to indicate starting s.jobManager.UpdateProgress(jobID, 5, "Starting app adoption...") s.jobManager.AddOutput(jobID, fmt.Sprintf("Adopting %s (%s)", app.AppName, app.InstallMethod)) // Create output channels outputChan := make(chan string, 100) errorChan := make(chan error, 10) cr := installer.NewCommandRunnerWithContext(ctx, outputChan, errorChan) // Goroutine to process output lines go func() { for output := range outputChan { s.jobManager.AddOutput(jobID, output) } }() // Goroutine to process errors go func() { for err := range errorChan { s.jobManager.AddOutput(jobID, "ERROR: "+err.Error()) } }() // Check for cancellation before proceeding if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled before it could start") return } // Get home directory homeDir, err := os.UserHomeDir() if err != nil { s.jobManager.SetError(jobID, err, "Failed to get home directory") return } configDir := installer.GetMPVConfigDirFromHome(homeDir) // Step 1: Configure mpv.conf s.jobManager.UpdateProgress(jobID, 20, "Configuring mpv.conf...") installer.InstallMPVConfigSafely(s.installer, cr) s.jobManager.AddOutput(jobID, "mpv.conf configured") // Check for cancellation if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled during execution") return } // Step 2: Configure Flatpak-specific settings if applicable if app.InstallMethod == constants.MethodMPVFlatpak { s.jobManager.UpdateProgress(jobID, 40, "Configuring Flatpak permissions...") s.jobManager.AddOutput(jobID, "Granting filesystem access to mpv config...") if err := cr.RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakMPV); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Flatpak: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Flatpak permissions configured") } } else if app.InstallMethod == constants.MethodCelluloidFlatpak { s.jobManager.UpdateProgress(jobID, 40, "Configuring Celluloid Flatpak...") if err := s.configureCelluloidFlatpakJob(jobID, cr, homeDir); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Celluloid: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Celluloid configured") } } else if app.InstallMethod == constants.MethodCelluloidPackage { s.jobManager.UpdateProgress(jobID, 40, "Configuring Celluloid...") if err := s.configureCelluloidPackageJob(jobID, cr, homeDir); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to configure Celluloid: "+err.Error()) } else { s.jobManager.AddOutput(jobID, "Celluloid configured") } } // Check for cancellation if job.IsCancelled() { s.jobManager.SetError(jobID, fmt.Errorf("adoption cancelled"), "Job was cancelled during execution") return } // Step 3: Install UI overlay if selected (only for MPV-based apps) if methodNeedsUISelection(app.InstallMethod) && uiType != constants.UITypeNone { s.jobManager.UpdateProgress(jobID, 60, "Installing UI overlay...") installer.InstallUISafely(s.installer, cr, configDir, uiType) s.jobManager.AddOutput(jobID, fmt.Sprintf("UI overlay '%s' installed", uiType)) } // Check for errors that may have occurred select { case err := <-errorChan: s.jobManager.SetError(jobID, err, "Failed during adoption") log.Error(constants.LogPrefixAPI + "Adopt job failed: " + err.Error()) return default: // No errors } // Step 4: Update app in config to mark as managed s.jobManager.UpdateProgress(jobID, 90, "Updating configuration...") uiTypeToSave := uiType if uiType == constants.UITypeNone { uiTypeToSave = "" } if err := config.UpdateInstalledAppManaged(app.AppName, app.InstallMethod, true, uiTypeToSave); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to update app in configuration: "+err.Error()) log.Error(constants.LogPrefixAPI + "Failed to update app managed status: " + err.Error()) } else { s.jobManager.AddOutput(jobID, "App marked as managed") } // Mark job as complete s.jobManager.CompleteJob(jobID) s.jobManager.AddOutput(jobID, "App adopted successfully!") log.Info(constants.LogPrefixAPI + "Adopt job completed successfully: " + app.AppName) } // configureCelluloidFlatpakJob configures Celluloid Flatpak with mpv config path (for job system) func (s *Server) configureCelluloidFlatpakJob(jobID string, cr *installer.CommandRunner, homeDir string) error { s.jobManager.AddOutput(jobID, "Configuring Celluloid Flatpak...") // Grant filesystem access s.jobManager.AddOutput(jobID, "Granting filesystem access to mpv config...") if err := cr.RunCommand("flatpak", "override", "--user", "--filesystem=xdg-config/mpv:ro", constants.FlatpakCelluloid); err != nil { return err } // Set mpv-config-enable s.jobManager.AddOutput(jobID, "Setting mpv-config-enable to true...") if err := cr.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-enable", "true"); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings (Celluloid may not have been run yet)") } // Set mpv-config-file configPath := installer.GetMPVConfigDirFromHome(homeDir) + "/mpv.conf" s.jobManager.AddOutput(jobID, fmt.Sprintf("Setting mpv-config-file to: %s", configPath)) if err := cr.RunCommand("flatpak", "run", "--command=gsettings", constants.FlatpakCelluloid, "set", constants.GSettingsCelluloidSchema, "mpv-config-file", configPath); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings mpv-config-file") } return nil } // configureCelluloidPackageJob configures Celluloid (package manager version) with mpv config path (for job system) func (s *Server) configureCelluloidPackageJob(jobID string, cr *installer.CommandRunner, homeDir string) error { s.jobManager.AddOutput(jobID, "Configuring Celluloid settings...") // Check if gsettings is available if _, err := exec.LookPath("gsettings"); err != nil { s.jobManager.AddOutput(jobID, "Warning: gsettings not found, skipping Celluloid configuration") return nil } // Set mpv-config-enable s.jobManager.AddOutput(jobID, "Setting mpv-config-enable to true...") if err := cr.RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-enable", "true"); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings (Celluloid may not have been run yet)") } // Set mpv-config-file configPath := installer.GetMPVConfigDirFromHome(homeDir) + "/mpv.conf" s.jobManager.AddOutput(jobID, fmt.Sprintf("Setting mpv-config-file to: %s", configPath)) if err := cr.RunCommand("gsettings", "set", "io.github.celluloid-player.Celluloid", "mpv-config-file", configPath); err != nil { s.jobManager.AddOutput(jobID, "Warning: Failed to set gsettings mpv-config-file") } return nil } // ============================================================================ // 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 "" } // 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") } }