tsplay/internal/cliamp/cliamp.go
28allday 8ee58b905f Initial commit: tuistream-play (tsplay) v0.1
Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:36:49 +01:00

141 lines
3.9 KiB
Go

// 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"
}