tsplay/internal/config/config.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

103 lines
2.6 KiB
Go

// Package config persists the small amount of state a Jellyfin client needs
// between runs: the server URL, the access token and user id obtained at login,
// a stable per-install device id, and the last-used playback volume. It lives at
// ~/.config/tsplay/config.json (XDG_CONFIG_HOME honoured).
package config
import (
"encoding/json"
"os"
"path/filepath"
"github.com/google/uuid"
)
// Config is the on-disk state. Token/UserID are empty until a successful login;
// the TUI shows the login screen whenever Token is blank.
type Config struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Token string `json:"token"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
// Volume is the last playback volume (mpv's 0-130 scale), remembered across
// tracks and runs. A nil pointer means "never set" — distinct from 0, which
// is a deliberate silent level.
Volume *float64 `json:"volume,omitempty"`
}
// VolumeOr returns the saved volume, or def if none has been stored yet.
func (c *Config) VolumeOr(def float64) float64 {
if c.Volume == nil {
return def
}
return *c.Volume
}
// SetVolume records v as the remembered volume.
func (c *Config) SetVolume(v float64) {
c.Volume = &v
}
// dir returns ~/.config/tsplay, creating it on demand.
func dir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
d := filepath.Join(base, "tsplay")
if err := os.MkdirAll(d, 0o700); err != nil {
return "", err
}
return d, nil
}
func path() (string, error) {
d, err := dir()
if err != nil {
return "", err
}
return filepath.Join(d, "config.json"), nil
}
// Load reads the saved config. A missing file is not an error: it returns a
// fresh Config with a newly minted DeviceID so first-run login can proceed.
func Load() (*Config, error) {
p, err := path()
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if os.IsNotExist(err) {
return &Config{DeviceID: uuid.NewString()}, nil
}
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}
if c.DeviceID == "" {
c.DeviceID = uuid.NewString()
}
return &c, nil
}
// Save writes the config back to disk with 0600 perms (it holds a token).
func (c *Config) Save() error {
p, err := path()
if err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o600)
}
// LoggedIn reports whether we have credentials to talk to the server.
func (c *Config) LoggedIn() bool {
return c.ServerURL != "" && c.Token != "" && c.UserID != ""
}