package config import ( "fmt" "os" "path/filepath" "slices" "strings" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/internal/mpvconf" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) // GetConfigPath returns the full path to mpv.conf (platform-aware) func GetConfigPath() string { configPath, err := GetMPVConfigFilePath() if err != nil { log.Warn(fmt.Sprintf("Failed to get MPV config path: %v", err)) return "" } return configPath } // GetConfigValue reads an explicit default-scope option. Only language // preferences are split into lists; other option values retain their commas. func GetConfigValue(key string) []string { values, err := ParseConfig(GetConfigPath()) if err != nil { log.Warn(fmt.Sprintf("Failed to read mpv.conf: %v", err)) } if result := values[key]; result != nil { return result } return []string{} } // ConfigValueUpdate describes one change in an atomic mpv.conf update. // Remove resets the global option; otherwise nil or empty Values write key=. type ConfigValueUpdate struct { Key string Values []string PreserveComments bool Remove bool } // SetConfigValue writes one config value to mpv.conf. The complete // read-modify-write cycle is serialized with all other editor updates. func SetConfigValue(key string, values []string, preserveComments bool) error { return SetConfigValues([]ConfigValueUpdate{{ Key: key, Values: values, PreserveComments: preserveComments, }}) } // SetConfigValues applies all updates to one fresh snapshot of mpv.conf and // commits them with one atomic replacement. This prevents concurrent callers // from losing unrelated edits and lets API handlers validate a complete batch // before any supplied field is persisted. func SetConfigValues(updates []ConfigValueUpdate) error { if len(updates) == 0 { return nil } for _, update := range updates { if update.Key == "" || strings.ContainsAny(update.Key, " \t\r\n\x00=#[]") { return fmt.Errorf("invalid config option name %q", update.Key) } for _, value := range update.Values { if strings.ContainsAny(value, "\r\n\x00") { return fmt.Errorf("config value for %q contains a line break or NUL", update.Key) } } } configPath := GetConfigPath() if configPath == "" { return fmt.Errorf("unable to determine MPV config path") } err := fileops.WithLock(configPath, func() error { configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, constants.DirPermission); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } data, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to read mpv.conf: %w", err) } log.Info("mpv.conf not found, creating from embedded asset") data, err = assets.ReadMPVConfig() if err != nil { return fmt.Errorf("failed to read embedded mpv.conf: %w", err) } } content := string(data) for _, update := range updates { content = applyConfigValue(content, update) } if err := writeFileAtomically(configPath, []byte(content)); err != nil { return err } log.Info(fmt.Sprintf("Updated mpv.conf at: %s", configPath)) return nil }) if err != nil { return fmt.Errorf("failed to create config directory or update mpv.conf: %w", err) } return nil } func applyConfigValue(content string, update ConfigValueUpdate) string { if update.Remove { updated, _ := mpvconf.Remove(content, update.Key, update.PreserveComments) return updated } updated, _ := mpvconf.Set(content, update.Key, strings.Join(update.Values, ","), update.PreserveComments) return updated } // ParseConfig reads one request snapshot of explicit default-scope settings. // Named profiles are retained in the file and evaluated by mpv, not this editor. func ParseConfig(path string) (map[string][]string, error) { values := make(map[string][]string) if path == "" { return values, nil } data, err := os.ReadFile(path) if os.IsNotExist(err) { return values, nil } if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } for key, value := range mpvconf.Parse(string(data)) { values[key] = []string{} if value == "" { continue } if key != constants.ConfigKeyAudioLanguage && key != constants.ConfigKeySubtitleLanguage { values[key] = []string{value} continue } for part := range strings.SplitSeq(value, ",") { if part = strings.TrimSpace(part); part != "" { values[key] = append(values[key], part) } } } return values, nil } // WriteConfig replaces the complete document with a deterministically sorted // map. It intentionally discards comments, profiles and omitted options. // Deprecated: use SetConfigValues to edit an existing user document safely. func WriteConfig(path string, config map[string][]string) error { var lines []string keys := make([]string, 0, len(config)) for key, values := range config { if key == "" || strings.ContainsAny(key, " \t\r\n\x00=#[]") { return fmt.Errorf("invalid config option name %q", key) } for _, value := range values { if strings.ContainsAny(value, "\r\n\x00") { return fmt.Errorf("config value for %q contains a line break or NUL", key) } } keys = append(keys, key) } slices.Sort(keys) for _, key := range keys { values := config[key] if len(values) == 0 { lines = append(lines, key+"=") } else if len(values) == 1 { lines = append(lines, key+"="+values[0]) } else { lines = append(lines, key+"="+strings.Join(values, ",")) } } newContent := strings.Join(lines, "\n") return fileops.WithLock(path, func() error { return writeFileAtomically(path, []byte(newContent)) }) } // writeFileAtomically writes content to a file using atomic operation // Creates temp file, writes content, then renames to final path func writeFileAtomically(path string, content []byte) error { return fileops.AtomicWritePreserve(path, content, constants.FilePermission) }