package web import ( "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" ) func (s *Server) handleConfigSettingsAPI(w http.ResponseWriter, r *http.Request) { settings := s.getConfigSettings() w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "audio_language": settings.AudioLanguage, "subtitle_language": settings.SubtitleLanguage, "hardware_acceleration": settings.HardwareAccel, "config_file": settings.ConfigFile, "save_position_on_quit": settings.SavePositionOnQuit, "screenshot_format": settings.ScreenshotFormat, "screenshot_tag_colorspace": settings.ScreenshotTagColorspace, "screenshot_high_bit_depth": settings.ScreenshotHighBitDepth, "screenshot_template": settings.ScreenshotTemplate, "screenshot_directory": settings.ScreenshotDirectory, "screenshot_jpeg_quality": settings.ScreenshotJpegQuality, "screenshot_png_compression": settings.ScreenshotPngCompression, "screenshot_webp_lossless": settings.ScreenshotWebpLossless, "screenshot_webp_quality": settings.ScreenshotWebpQuality, "screenshot_jxl_distance": settings.ScreenshotJxlDistance, "screenshot_avif_encoder": settings.ScreenshotAvifEncoder, "screenshot_avif_pixfmt": settings.ScreenshotAvifPixfmt, "screenshot_avif_opts": settings.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() query := strings.TrimSpace(r.URL.Query().Get("q")) offset := 0 if rawOffset := r.URL.Query().Get("offset"); rawOffset != "" { parsedOffset, err := strconv.Atoi(rawOffset) if err != nil || parsedOffset < 0 { http.Error(w, "invalid backup list offset", http.StatusBadRequest) return } offset = parsedOffset } data := buildConfigBackupsListData(backups, query, offset) templateName := "config-backups-list" if r.URL.Query().Get("append") == "1" && offset > 0 { templateName = "config-backups-more" } w.Header().Set("Content-Type", constants.ContentTypeHTML) err := s.templates.ExecuteTemplate(w, templateName, data) if err != nil { log.Error("Failed to render " + templateName + " template: " + err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) } } 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 } } // Complete validation before the first write. The individual apply blocks // below retain their defensive checks, but no invalid later field can leave // earlier fields committed. if err := validateConfigApplyRequest(req); err != nil { log.Error(constants.LogPrefixAPI + "Invalid config apply request: " + err.Error()) sendJSONError(w, http.StatusBadRequest, err.Error()) return } updates := configUpdatesForApply(req) previousHWA := "" managerPreferenceChanged := req.HardwareAccel != nil if managerPreferenceChanged { previousHWA = config.GetHWADecoderPreference() if err := config.SetHWADecoderPreference(*req.HardwareAccel); err != nil { log.Error(constants.LogPrefixAPI + "Failed to save HWA preference: " + err.Error()) sendJSONError(w, http.StatusInternalServerError, err.Error()) return } } if err := config.SetConfigValues(updates); err != nil { if managerPreferenceChanged { if rollbackErr := config.SetHWADecoderPreference(previousHWA); rollbackErr != nil { err = errors.Join(err, fmt.Errorf("restore HWA preference: %w", rollbackErr)) } } log.Error(constants.LogPrefixAPI + "Failed to atomically apply config: " + 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 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 configUpdatesForApply(req ConfigApplyRequest) []config.ConfigValueUpdate { candidates := []struct { key string value *string }{ {constants.ConfigKeyAudioLanguage, req.AudioLanguage}, {constants.ConfigKeySubtitleLanguage, req.SubtitleLanguage}, {constants.ConfigKeyHardwareDecoder, req.HardwareAccel}, {constants.ConfigKeyVideoOutput, req.VideoOutputDriver}, {constants.ConfigKeyScaleFilter, req.VideoScaleFilter}, {constants.ConfigKeyDitherAlgorithm, req.DitherAlgorithm}, {constants.ConfigKeySavePositionOnQuit, req.SavePositionOnQuit}, {constants.ConfigKeyBorder, req.Border}, {constants.ConfigKeyProfile, req.Profile}, {constants.ConfigKeyScreenshotFormat, req.ScreenshotFormat}, {constants.ConfigKeyScreenshotTagColorspace, req.ScreenshotTagColorspace}, {constants.ConfigKeyScreenshotHighBitDepth, req.ScreenshotHighBitDepth}, {constants.ConfigKeyScreenshotTemplate, req.ScreenshotTemplate}, {constants.ConfigKeyScreenshotDirectory, req.ScreenshotDirectory}, {constants.ConfigKeyScreenshotJpegQuality, req.ScreenshotJpegQuality}, {constants.ConfigKeyScreenshotPngCompression, req.ScreenshotPngCompression}, {constants.ConfigKeyScreenshotWebpLossless, req.ScreenshotWebpLossless}, {constants.ConfigKeyScreenshotWebpQuality, req.ScreenshotWebpQuality}, {constants.ConfigKeyScreenshotJxlDistance, req.ScreenshotJxlDistance}, {constants.ConfigKeyScreenshotAvifEncoder, req.ScreenshotAvifEncoder}, {constants.ConfigKeyScreenshotAvifPixfmt, req.ScreenshotAvifPixfmt}, {constants.ConfigKeyScreenshotAvifOpts, req.ScreenshotAvifOpts}, } updates := make([]config.ConfigValueUpdate, 0, len(candidates)) for _, candidate := range candidates { if candidate.value == nil { continue } // Clearing an option resets it to mpv defaults. Empty language lists // instead remain explicit so mpv does not inherit its default priority. remove := *candidate.value == "" && candidate.key != constants.ConfigKeyAudioLanguage && candidate.key != constants.ConfigKeySubtitleLanguage updates = append(updates, config.ConfigValueUpdate{ Key: candidate.key, Values: []string{*candidate.value}, PreserveComments: true, Remove: remove, }) } return updates } func validateConfigApplyRequest(req ConfigApplyRequest) error { type candidate struct { label string key string value *string validate func(string, string) error } configValue := func(key, value string) error { return ValidateConfigValue(key, value) } language := func(_ string, value string) error { return ValidateLanguageCode(value) } hwa := func(_ string, value string) error { return ValidateHWAValue(value) } quality := func(_ string, value string) error { return ValidateScreenshotQuality(value) } compression := func(_ string, value string) error { return ValidateScreenshotCompression(value) } jxlDistance := func(_ string, value string) error { return ValidateScreenshotJxlDistance(value) } candidates := []candidate{ {"audio language", constants.ConfigKeyAudioLanguage, req.AudioLanguage, language}, {"subtitle language", constants.ConfigKeySubtitleLanguage, req.SubtitleLanguage, language}, {"hardware acceleration", constants.ConfigKeyHardwareDecoder, req.HardwareAccel, hwa}, {"video output driver", constants.ConfigKeyVideoOutput, req.VideoOutputDriver, configValue}, {"video scale filter", constants.ConfigKeyScaleFilter, req.VideoScaleFilter, configValue}, {"dither algorithm", constants.ConfigKeyDitherAlgorithm, req.DitherAlgorithm, configValue}, {"save position on quit", constants.ConfigKeySavePositionOnQuit, req.SavePositionOnQuit, configValue}, {"border", constants.ConfigKeyBorder, req.Border, configValue}, {"profile", constants.ConfigKeyProfile, req.Profile, configValue}, {"screenshot format", constants.ConfigKeyScreenshotFormat, req.ScreenshotFormat, configValue}, {"screenshot tag colorspace", constants.ConfigKeyScreenshotTagColorspace, req.ScreenshotTagColorspace, configValue}, {"screenshot high bit depth", constants.ConfigKeyScreenshotHighBitDepth, req.ScreenshotHighBitDepth, configValue}, {"screenshot template", constants.ConfigKeyScreenshotTemplate, req.ScreenshotTemplate, configValue}, {"screenshot directory", constants.ConfigKeyScreenshotDirectory, req.ScreenshotDirectory, configValue}, {"screenshot JPEG quality", constants.ConfigKeyScreenshotJpegQuality, req.ScreenshotJpegQuality, quality}, {"screenshot PNG compression", constants.ConfigKeyScreenshotPngCompression, req.ScreenshotPngCompression, compression}, {"screenshot WebP lossless", constants.ConfigKeyScreenshotWebpLossless, req.ScreenshotWebpLossless, configValue}, {"screenshot WebP quality", constants.ConfigKeyScreenshotWebpQuality, req.ScreenshotWebpQuality, quality}, {"screenshot JXL distance", constants.ConfigKeyScreenshotJxlDistance, req.ScreenshotJxlDistance, jxlDistance}, {"screenshot AVIF encoder", constants.ConfigKeyScreenshotAvifEncoder, req.ScreenshotAvifEncoder, configValue}, {"screenshot AVIF pixel format", constants.ConfigKeyScreenshotAvifPixfmt, req.ScreenshotAvifPixfmt, configValue}, {"screenshot AVIF options", constants.ConfigKeyScreenshotAvifOpts, req.ScreenshotAvifOpts, configValue}, } for _, item := range candidates { if item.value == nil || *item.value == "" { continue } if err := item.validate(item.key, *item.value); err != nil { return fmt.Errorf("invalid %s: %w", item.label, err) } } return nil } func (s *Server) handleConfigResetAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { sendJSONError(w, http.StatusMethodNotAllowed, constants.ResponseMessageMethodNotAllowed) return } outputChan := make(chan string, 1000) errorChan := make(chan error, 10) cr := installer.NewCommandRunner(outputChan, errorChan) err := s.installerForOperation(config.GetInstallPath()).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.installerForOperation(config.GetInstallPath()).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 through the descriptor-relative backup root so a concurrent path // swap cannot redirect the operation. if err := config.RemoveBackupFile(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 getConfigBackups() []ConfigBackup { backupDir, err := config.GetMPVConfigBackupDir() if err != nil { return []ConfigBackup{} } return listConfigBackups(backupDir) } const configBackupsPageSize = 12 // configBackupsListData is shared by the initial Config page and HTMX list // refreshes so both paths apply the same search and pagination rules. type configBackupsListData struct { ConfigBackups []ConfigBackup Query string Total int AllTotal int VisibleEnd int NextOffset int HasMore bool } func buildConfigBackupsListData(backups []ConfigBackup, query string, offset int) configBackupsListData { query = strings.TrimSpace(query) filtered := backups if query != "" { needle := strings.ToLower(query) filtered = make([]ConfigBackup, 0, len(backups)) for _, backup := range backups { haystack := strings.ToLower(backup.Filename + " " + backup.CreatedDate + " " + backup.FilePath) if strings.Contains(haystack, needle) { filtered = append(filtered, backup) } } } if offset < 0 { offset = 0 } if offset > len(filtered) { offset = len(filtered) } end := offset + configBackupsPageSize if end > len(filtered) { end = len(filtered) } return configBackupsListData{ ConfigBackups: filtered[offset:end], Query: query, Total: len(filtered), AllTotal: len(backups), VisibleEnd: end, NextOffset: end, HasMore: end < len(filtered), } } // listConfigBackups returns the .conf backups in backupDir, newest first. func listConfigBackups(backupDir string) []ConfigBackup { type backupFile struct { name string modTime time.Time } var files []backupFile if entries, err := os.ReadDir(backupDir); err == nil { for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { info, err := entry.Info() if err != nil || !info.Mode().IsRegular() { continue } files = append(files, backupFile{name: entry.Name(), modTime: info.ModTime()}) } } } sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) }) var items []ConfigBackup for _, f := range files { items = append(items, ConfigBackup{ Filename: f.name, CreatedDate: f.modTime.Format("2006-01-02 15:04:05"), FilePath: filepath.Join(backupDir, f.name), }) } return items } // getConfigBackupDir returns the directory where MPV config backups are stored. func getConfigBackupDir() string { backupDir, err := config.GetMPVConfigBackupDir() if err != nil { return "" } return backupDir } // configPageData extends DashboardData with the resolved backup directory // shown on the config page. type configPageData struct { DashboardData ConfigBackupDir string BackupList configBackupsListData } // applyConfigPageDefaults fills unset select values with the page's default // options (mirroring the border default in getConfigSettings) so the rendered // selects reflect the effective setting instead of the browser showing the // first "(Recommended)" option for unset keys. func applyConfigPageDefaults(cfg *ConfigSettings) { if cfg.VideoOutputDriver == "" { cfg.VideoOutputDriver = "gpu-next" } if cfg.Profile == "" { cfg.Profile = "high-quality" } if cfg.VideoScaleFilter == "" { cfg.VideoScaleFilter = "ewa_lanczossharp" } if cfg.SavePositionOnQuit == "" { cfg.SavePositionOnQuit = "no" } if cfg.ScreenshotFormat == "" { cfg.ScreenshotFormat = "webp" } if cfg.ScreenshotTagColorspace == "" { cfg.ScreenshotTagColorspace = "yes" } if cfg.ScreenshotHighBitDepth == "" { cfg.ScreenshotHighBitDepth = "yes" } if cfg.ScreenshotWebpLossless == "" { cfg.ScreenshotWebpLossless = "no" } } func getConfigFilePath() string { configPath, err := config.GetMPVConfigFilePath() if err != nil { return "" } return configPath }