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: `