// Package mpvconf reads and edits explicit default-scope mpv.conf options. // Named profiles are preserved, not evaluated; mpv applies them at runtime. package mpvconf import ( "fmt" "strconv" "strings" ) type assignment struct { key, value, prefix, comment string } // Parse returns the last explicit assignment for each default-scope option. // Commas remain part of the value; callers decide which options are lists. func Parse(content string) map[string]string { values := make(map[string]string) global := true for _, line := range strings.Split(strings.TrimPrefix(content, "\ufeff"), "\n") { if profile, ok := profileName(line); ok { global = profile == "default" } else if entry, ok := parseAssignment(line); global && ok { values[entry.key] = entry.value } } return values } type Change struct { Found bool Previous string Duplicates int } // Set updates only default-scope assignments, removing older duplicates and // preserving profiles, unrelated lines, BOM, line endings and inline comments. // Missing options are inserted before the first profile so they remain global. func Set(content, key, value string, preserveComments bool) (string, Change) { return edit(content, key, &value, preserveComments) } // Remove deletes all default-scope assignments for key. Named profiles and // unrelated text remain intact; retained inline comments become comment lines. func Remove(content, key string, preserveComments bool) (string, Change) { return edit(content, key, nil, preserveComments) } func edit(content, key string, value *string, preserveComments bool) (string, Change) { bom := "" if strings.HasPrefix(content, "\ufeff") { bom, content = "\ufeff", strings.TrimPrefix(content, "\ufeff") } newline := "\n" if strings.Contains(content, "\r\n") { newline = "\r\n" } lines := strings.SplitAfter(content, "\n") matches := make(map[int]assignment) last, firstProfile := -1, -1 global := true for index, line := range lines { if profile, ok := profileName(line); ok { global = profile == "default" if firstProfile < 0 { firstProfile = index } } else if entry, ok := parseAssignment(line); global && ok && entry.key == key { matches[index], last = entry, index } } encoded := "" if value != nil { encoded = quoteValue(key, *value) } if last < 0 { if value == nil { return bom + content, Change{} } entry := key + "=" + encoded + newline if firstProfile >= 0 { return bom + strings.Join(lines[:firstProfile], "") + entry + strings.Join(lines[firstProfile:], ""), Change{} } if content != "" && !strings.HasSuffix(content, "\n") { content += newline } return bom + content + entry, Change{} } change := Change{Found: true, Previous: matches[last].value, Duplicates: len(matches) - 1} var out strings.Builder out.WriteString(bom) for index, line := range lines { entry, matching := matches[index] if !matching { out.WriteString(line) continue } ending := "" if strings.HasSuffix(line, "\n") { ending = "\n" if strings.HasSuffix(line, "\r\n") { ending = "\r\n" } } if index != last || value == nil { if preserveComments && entry.comment != "" { out.WriteString(strings.TrimLeft(entry.comment, " \t") + ending) } continue } out.WriteString(entry.prefix + encoded) if preserveComments { out.WriteString(entry.comment) } out.WriteString(ending) } return out.String(), change } func profileName(line string) (string, bool) { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "[") { return "", false } end := strings.IndexByte(line, ']') if end < 0 { return "", false } tail := strings.TrimSpace(line[end+1:]) return strings.TrimSpace(line[1:end]), tail == "" || strings.HasPrefix(tail, "#") } func parseAssignment(line string) (assignment, bool) { line = strings.TrimRight(line, "\r\n") text := strings.TrimLeft(line, " \t") if text == "" || text[0] == '#' || text[0] == '[' { return assignment{}, false } keyEnd := strings.IndexAny(text, "= \t#") if keyEnd < 0 { keyEnd = len(text) } key := text[:keyEnd] if key == "" { return assignment{}, false } rest := strings.TrimLeft(text[keyEnd:], " \t") if rest == "" || rest[0] == '#' { comment := text[keyEnd:] return assignment{key: key, value: "yes", prefix: line[:len(line)-len(text)] + key + "=", comment: comment}, true } if rest[0] != '=' { return assignment{}, false } rest = rest[1:] value := strings.TrimLeft(rest, " \t") prefix := line[:len(line)-len(value)] end := 0 switch { case len(value) > 0 && (value[0] == '"' || value[0] == '\''): closing := strings.IndexByte(value[1:], value[0]) if closing < 0 { return assignment{}, false } end = closing + 2 value = value[1 : end-1] case strings.HasPrefix(value, "%"): closing := strings.IndexByte(value[1:], '%') if closing < 0 { return assignment{}, false } header := closing + 2 length, err := strconv.Atoi(value[1 : header-1]) if err != nil || length < 0 || length > len(value)-header { return assignment{}, false } end = header + length value = value[header:end] default: end = strings.IndexByte(value, '#') if end < 0 { end = len(value) } value = strings.TrimRight(value[:end], " \t") end = len(value) } tail := line[len(prefix)+end:] trimmedTail := strings.TrimSpace(tail) if trimmedTail != "" && !strings.HasPrefix(trimmedTail, "#") { return assignment{}, false } if trimmedTail == "" { tail = "" } return assignment{key: key, value: value, prefix: prefix, comment: tail}, true } func quoteValue(key, value string) string { if value == "" { return "" } needsQuotes := key == "screenshot-directory" || key == "screenshot-template" || strings.Contains(value, "#") || strings.TrimSpace(value) != value || strings.ContainsAny(value[:1], "\"'%") if !needsQuotes { return value } if !strings.ContainsRune(value, '"') { return `"` + value + `"` } if !strings.ContainsRune(value, '\'') { return "'" + value + "'" } // mpv.conf does not interpret C-style escapes. Its fixed-length syntax // safely represents literal quotes, hashes and Windows backslashes. return fmt.Sprintf("%%%d%%%s", len(value), value) }