// Package scriptopts implements the shared machinery for editing mpv // script-opts conf files (modernz.conf, uosc.conf): parsing that preserves // comments and untouched lines, curated option get/set/reset, and atomic // writes with timestamped backups. pkg/modernzconf and pkg/uoscconf are thin // wrappers that supply their own option catalogs and file naming. package scriptopts import ( "crypto/sha256" "errors" "fmt" "os" "path/filepath" "slices" "strings" "time" "gitgud.io/mike/mpv-manager/internal/confline" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) // ScriptOptsDirName is the mpv config subdirectory holding script-opts files. const ScriptOptsDirName = "script-opts" const maxBackupsPerScriptOptsFile = 20 // ErrStaleSnapshot means a parsed File no longer describes the bytes on disk. // Callers should reparse and reapply their edit instead of overwriting a newer // writer's changes. var ErrStaleSnapshot = errors.New("script-options snapshot is stale") // ConfPath returns the platform-aware path to the named script-opts conf // file, in the script-opts directory next to mpv.conf. func ConfPath(fileName string) (string, error) { configPath, err := config.GetMPVConfigFilePath() if err != nil { return "", fmt.Errorf("failed to get MPV config path: %w", err) } dir := filepath.Dir(configPath) return filepath.Join(dir, ScriptOptsDirName, fileName), nil } // Spec describes one script-opts conf flavor: its file name (also used in // error and log messages) and its curated option catalog. type Spec struct { Name string // conf file name, e.g. "modernz.conf" Options []Option // curated editable options } // LookupOption returns the known option with the given key. func (s *Spec) LookupOption(key string) (Option, bool) { for _, opt := range s.Options { if opt.Key == key { return opt, true } } return Option{}, false } // line is a single parsed line of a script-opts conf file. type line struct { raw string // original line text for round-trip preservation isComment bool // pure comment line (starts with #) isEmpty bool // blank line isEntry bool // key=value option line key string value string comment string // inline comment text (without "#") } // File holds the parsed representation of a script-opts conf file. type File struct { spec *Spec lines []line filePath string newline string hasBOM bool revision fileRevision } type fileRevision struct { exists bool digest [sha256.Size]byte } // Parse reads and parses the conf file at path. // A missing file is not an error: the returned file has no explicit entries // and GetValue falls back to the spec's option defaults. func Parse(spec *Spec, path string) (*File, error) { f := &File{spec: spec, filePath: path, newline: "\n"} info, err := os.Lstat(path) if err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) { return nil, fmt.Errorf("%s is not a regular file", spec.Name) } if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("failed to inspect %s: %w", spec.Name, err) } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return f, nil } return nil, fmt.Errorf("failed to read %s: %w", spec.Name, err) } f = ParseContent(spec, path, data) return f, nil } // ParseContent parses a captured file snapshot without reading from disk. // The returned file retains that revision for the usual stale-write guard. func ParseContent(spec *Spec, path string, data []byte) *File { f := &File{spec: spec, filePath: path, newline: "\n", revision: revisionForData(data)} content := string(data) if strings.HasPrefix(content, "\ufeff") { f.hasBOM = true content = strings.TrimPrefix(content, "\ufeff") } if strings.Contains(content, "\r\n") { f.newline = "\r\n" } for _, l := range strings.Split(content, f.newline) { f.lines = append(f.lines, parseLine(l)) } return f } // GetValue returns the explicitly set value for key, or the spec's option // default when the user has no explicit line. Unknown keys return "". func (f *File) GetValue(key string) string { if i := f.findEntry(key); i >= 0 { return f.lines[i].value } if opt, ok := f.spec.LookupOption(key); ok { return opt.Default } return "" } // IsSet reports whether the user has an explicit line for key. func (f *File) IsSet(key string) bool { return f.findEntry(key) >= 0 } // SetValue sets key to value after validating it against the spec's options. // An existing line is updated in place (preserving position and inline // comment); a new key is appended at the end of the file. func (f *File) SetValue(key, value string) error { if err := ValidateValue(f.spec, key, value); err != nil { return err } if i := f.findEntry(key); i >= 0 { f.lines[i].value = value f.lines[i].raw = serializeEntry(f.lines[i]) return nil } entry := line{isEntry: true, key: key, value: value} entry.raw = serializeEntry(entry) // Insert before trailing blank lines so appended options stay tidy. pos := len(f.lines) for pos > 0 && f.lines[pos-1].isEmpty { pos-- } f.lines = slices.Insert(f.lines, pos, entry) return nil } // ResetValue removes the explicit line(s) for key, reverting it to the // spec's option default. Unknown keys are rejected. func (f *File) ResetValue(key string) error { if _, ok := f.spec.LookupOption(key); !ok { return fmt.Errorf("unknown %s option %q", f.spec.Name, key) } kept := f.lines[:0] for _, l := range f.lines { if l.isEntry && l.key == key { continue } kept = append(kept, l) } f.lines = kept return nil } // Write writes the file back to disk using an atomic write pattern. // A timestamped backup of any existing file is created before overwriting. func (f *File) Write() error { if f.filePath == "" { return fmt.Errorf("%s path is empty", f.spec.Name) } return fileops.WithLock(f.filePath, func() error { return f.writeLocked() }) } func (f *File) writeLocked() error { currentRevision, err := readFileRevision(f.filePath) if err != nil { return fmt.Errorf("failed to verify current %s snapshot: %w", f.spec.Name, err) } if currentRevision != f.revision { return fmt.Errorf("%w: %s changed after it was parsed", ErrStaleSnapshot, f.spec.Name) } // Create backup of existing file if it exists if currentRevision.exists { if backupErr := f.createBackup(); backupErr != nil { return fmt.Errorf("failed to create %s backup: %w", f.spec.Name, backupErr) } } if err := os.MkdirAll(filepath.Dir(f.filePath), constants.DirPermission); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } content := []byte(f.Serialize()) if err := AtomicWrite(f.filePath, content); err != nil { return err } f.revision = revisionForData(content) return nil } func revisionForData(data []byte) fileRevision { return fileRevision{exists: true, digest: sha256.Sum256(data)} } func readFileRevision(path string) (fileRevision, error) { info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { return fileRevision{}, nil } return fileRevision{}, err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fileRevision{}, fmt.Errorf("%s is not a regular file", path) } data, err := os.ReadFile(path) if err != nil { return fileRevision{}, err } return revisionForData(data), nil } // SetValueFile applies one option to a fresh file snapshot while holding the // path lock through backup and atomic replacement. func SetValueFile(spec *Spec, path, key, value string) error { if err := ValidateValue(spec, key, value); err != nil { return err } return fileops.WithLock(path, func() error { return SetValueFileLocked(spec, path, key, value) }) } // SetValueFileLocked applies one option without taking the path lock. It is // intended for higher-level transactions that already hold fileops.WithLock // across this file and another durable resource. func SetValueFileLocked(spec *Spec, path, key, value string) error { if err := ValidateValue(spec, key, value); err != nil { return err } f, err := Parse(spec, path) if err != nil { return err } if err := f.SetValue(key, value); err != nil { return err } return f.writeLocked() } // ResetValueFile removes one explicit option from a fresh file snapshot while // holding the path lock through backup and atomic replacement. func ResetValueFile(spec *Spec, path, key string) error { if _, ok := spec.LookupOption(key); !ok { return fmt.Errorf("unknown %s option %q", spec.Name, key) } return fileops.WithLock(path, func() error { f, err := Parse(spec, path) if err != nil { return err } if err := f.ResetValue(key); err != nil { return err } return f.writeLocked() }) } // Serialize converts the file back to its text representation. func (f *File) Serialize() string { lines := make([]string, len(f.lines)) for i, l := range f.lines { lines[i] = l.raw } prefix := "" if f.hasBOM { prefix = "\ufeff" } return prefix + strings.Join(lines, f.newline) } // findEntry returns the index of the effective (last) entry line for key. func (f *File) findEntry(key string) int { for i := len(f.lines) - 1; i >= 0; i-- { if f.lines[i].isEntry && f.lines[i].key == key { return i } } return -1 } // parseLine parses a single script-opts conf line. // // The format is: // // key=value [# comment] // # comment line // (blank lines) func parseLine(l string) line { trimmed := strings.TrimSpace(l) if trimmed == "" { return line{raw: l, isEmpty: true} } if strings.HasPrefix(trimmed, "#") { return line{raw: l, isComment: true} } // Inline comments must be preceded by whitespace so values containing // "#" (e.g. hex colors) are not misparsed. body := trimmed comment := "" if idx := confline.CommentIndex(trimmed, true); idx != -1 { comment = trimmed[idx+1:] body = strings.TrimSpace(trimmed[:idx]) } eq := strings.Index(body, "=") if eq == -1 { // Unrecognized line; keep it verbatim. return line{raw: l} } return line{ raw: l, isEntry: true, key: strings.TrimSpace(body[:eq]), value: strings.TrimSpace(body[eq+1:]), comment: comment, } } // serializeEntry converts an entry line back to its text form. func serializeEntry(l line) string { s := l.key + "=" + l.value if l.comment != "" { s += " #" + l.comment } return s } // AtomicWrite writes content to a file using an atomic rename pattern. func AtomicWrite(path string, content []byte) error { return fileops.AtomicWritePreserve(path, content, constants.FilePermission) } // createBackup creates a timestamped backup copy of the conf file. func (f *File) createBackup() error { info, err := os.Lstat(f.filePath) if err != nil { return fmt.Errorf("failed to inspect file for backup: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to back up non-regular file %s", f.filePath) } data, err := os.ReadFile(f.filePath) if err != nil { return fmt.Errorf("failed to read file for backup: %w", err) } timestamp := time.Now().Format("2006-01-02_150405.000000000") pattern := filepath.Base(f.filePath) + "-" + f.spec.Name + ".bak" + "." + timestamp + ".*" backupPath, err := fileops.WriteUnique(filepath.Dir(f.filePath), pattern, data, info.Mode().Perm()) if err != nil { return fmt.Errorf("failed to create backup file: %w", err) } if err := fileops.PruneBackups(filepath.Dir(f.filePath), filepath.Base(f.filePath)+"-"+f.spec.Name+".bak.*", maxBackupsPerScriptOptsFile); err != nil { return fmt.Errorf("failed to prune old backups: %w", err) } log.Info(fmt.Sprintf("Created %s backup: %s", f.spec.Name, backupPath)) return nil }