// Package cliamp mirrors tsplay's Jellyfin credentials into cliamp's config so // cliamp's Jellyfin music provider works without a second, separate setup. // cliamp ships by default on Omarchy; when it's present we write a [jellyfin] // table into ~/.config/cliamp/config.toml at login. // // The write is a careful merge: only the [jellyfin] table is replaced (or // appended); every other section and comment in the user's config is preserved. package cliamp import ( "os" "os/exec" "path/filepath" "strings" ) // Present reports whether cliamp appears to be installed — either the binary is // on PATH or its config directory already exists. We only write credentials // when cliamp is actually in use, so tsplay never litters config on systems // that don't have it. func Present() bool { if _, err := exec.LookPath("cliamp"); err == nil { return true } if d, err := configDir(); err == nil { if _, err := os.Stat(d); err == nil { return true } } return false } func configDir() (string, error) { base, err := os.UserConfigDir() if err != nil { return "", err } return filepath.Join(base, "cliamp"), nil } // Creds are the Jellyfin details to write into cliamp's [jellyfin] block. Empty // fields are omitted, so callers can write token+user_id or user+password. type Creds struct { URL string User string Password string Token string UserID string } // WriteJellyfin merges a [jellyfin] section into cliamp's config.toml, creating // the file/dir (0700/0600) if needed and preserving every other section and // comment already in the file. Only the [jellyfin] table is rewritten. func WriteJellyfin(c Creds) error { dir, err := configDir() if err != nil { return err } if err := os.MkdirAll(dir, 0o700); err != nil { return err } path := filepath.Join(dir, "config.toml") existing, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return err } merged := mergeSection(string(existing), "jellyfin", c.block()) return os.WriteFile(path, []byte(merged), 0o600) } // block renders the [jellyfin] section (header + non-empty keys), no trailing // newline. func (c Creds) block() string { var b strings.Builder b.WriteString("[jellyfin]\n") writeKV(&b, "url", c.URL) writeKV(&b, "user", c.User) writeKV(&b, "password", c.Password) writeKV(&b, "token", c.Token) writeKV(&b, "user_id", c.UserID) return strings.TrimRight(b.String(), "\n") } func writeKV(b *strings.Builder, key, val string) { if val == "" { return } b.WriteString(key) b.WriteString(` = "`) b.WriteString(tomlEscape(val)) b.WriteString("\"\n") } // tomlEscape escapes the characters that matter inside a basic TOML string. func tomlEscape(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) return s } // mergeSection returns doc with the named TOML table replaced by block (which // itself begins with the table header). If the table is absent, block is // appended after a blank line. All other content — comments and other tables — // is preserved verbatim. The result always ends in a single newline. func mergeSection(doc, name, block string) string { header := "[" + name + "]" lines := strings.Split(doc, "\n") start := -1 for i, ln := range lines { if strings.TrimSpace(ln) == header { start = i break } } if start == -1 { trimmed := strings.TrimRight(doc, "\n") if trimmed == "" { return block + "\n" } return trimmed + "\n\n" + block + "\n" } // The existing table runs until the next table header line, or EOF. end := len(lines) for i := start + 1; i < len(lines); i++ { t := strings.TrimSpace(lines[i]) if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") { end = i break } } var out []string out = append(out, lines[:start]...) out = append(out, strings.Split(block, "\n")...) out = append(out, lines[end:]...) return strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n" }