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>
141 lines
3.9 KiB
Go
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"
|
|
}
|