Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ shuffle = false
# Start with mono output (L+R downmix)
mono = false

# Restore last playlist/track/position when reopening with no CLI args
resume_session = true

# Shift+Left/Right seek jump in seconds (6-600)
seek_large_step_sec = 30

Expand Down
12 changes: 8 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string

// Config holds user preferences loaded from the config file.
type Config struct {
Volume float64 // dB, range [-30, +6]
EQ [10]float64 // per-band gain in dB, range [-12, +12]
EQPreset string // preset name, or "" for custom
Repeat string // "off", "all", or "one"
Volume float64 // dB, range [-30, +6]
EQ [10]float64 // per-band gain in dB, range [-12, +12]
EQPreset string // preset name, or "" for custom
Repeat string // "off", "all", or "one"
Shuffle bool
Mono bool
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
Expand All @@ -123,6 +123,7 @@ type Config struct {
ResampleQuality int // beep resample quality factor (1–4)
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32
Compact bool // compact mode: cap frame width at 80 columns
ResumeSession bool // restore last playlist/index/position on startup when no args
Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials
Spotify SpotifyConfig // optional Spotify provider (requires Premium)
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
Expand All @@ -136,6 +137,7 @@ func Default() Config {
return Config{
Repeat: "off",
SeekStepLarge: 30,
ResumeSession: true,
SampleRate: 0,
BufferMs: 100,
ResampleQuality: 4,
Expand Down Expand Up @@ -271,6 +273,8 @@ func Load() (Config, error) {
}
case "compact":
cfg.Compact = val == "true"
case "resume_session":
cfg.ResumeSession = val == "true"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Overrides struct {
BitDepth *int
Play *bool
Compact *bool
Takeover *bool
}

// Apply merges non-nil overrides into cfg and clamps the result.
Expand Down Expand Up @@ -105,6 +106,8 @@ func ParseFlags(args []string) (action string, ov Overrides, positional []string
ov.Play = ptrBool(true)
case "--compact":
ov.Compact = ptrBool(true)
case "--takeover":
ov.Takeover = ptrBool(true)

// Key-value flags.
case "--provider":
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ shuffle = false
# Start with mono output (L+R downmix)
mono = false

# Restore last playlist/track/position when reopening with no CLI args
resume_session = true

# Shift+Left/Right seek jump in seconds
seek_large_step_sec = 30

Expand Down
11 changes: 11 additions & 0 deletions internal/instance/lock_nonunix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !unix

package instance

// Lock is a no-op on non-unix platforms.
type Lock struct{}

// Acquire is a no-op on non-unix platforms.
func Acquire(takeover bool) (*Lock, error) { return &Lock{}, nil }

func (l *Lock) Close() {}
143 changes: 143 additions & 0 deletions internal/instance/lock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//go:build unix

package instance

import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"

"cliamp/internal/appdir"
)

const (
takeoverPollInterval = 100 * time.Millisecond
takeoverTimeout = 8 * time.Second
takeoverKillAfter = 3 * time.Second
)

type Lock struct {
f *os.File
}

// LockedError reports that another cliamp instance currently holds the lock.
type LockedError struct {
PID int
}

func (e LockedError) Error() string {
if e.PID > 0 {
return fmt.Sprintf("another cliamp instance is running (pid %d). Re-run with --takeover to stop it.", e.PID)
}
return "another cliamp instance is running. Re-run with --takeover to stop it."
}

func lockFile() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "session.lock"), nil
}

func readPID(f *os.File) int {
if _, err := f.Seek(0, 0); err != nil {
return 0
}
data, err := os.ReadFile(f.Name())
if err != nil {
return 0
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
return pid
}

func writePID(f *os.File, pid int) {
if err := f.Truncate(0); err != nil {
return
}
if _, err := f.Seek(0, 0); err != nil {
return
}
_, _ = fmt.Fprintf(f, "%d\n", pid)
}

func tryLock(f *os.File) error {
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
}

// Acquire obtains a process lock for cliamp.
// If takeover is true, it sends SIGTERM to the lock holder and retries.
func Acquire(takeover bool) (*Lock, error) {
path, err := lockFile()
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
lockNow := func() error {
err := tryLock(f)
if err == nil {
writePID(f, os.Getpid())
return nil
}
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return err
}
return err
}

if err := lockNow(); err == nil {
return &Lock{f: f}, nil
} else if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
_ = f.Close()
return nil, err
}

pid := readPID(f)
if !takeover {
_ = f.Close()
return nil, LockedError{PID: pid}
}

if pid > 0 && pid != os.Getpid() {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Signal(syscall.SIGTERM)
}
}
start := time.Now()
deadline := start.Add(takeoverTimeout)
sentKill := false
for time.Now().Before(deadline) {
time.Sleep(takeoverPollInterval)
if err := lockNow(); err == nil {
return &Lock{f: f}, nil
}
if !sentKill && pid > 0 && time.Since(start) >= takeoverKillAfter {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Signal(syscall.SIGKILL)
}
sentKill = true
}
}
_ = f.Close()
return nil, fmt.Errorf("failed to take over running instance (pid %d)", pid)
}

func (l *Lock) Close() {
if l == nil || l.f == nil {
return
}
_ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN)
_ = l.f.Close()
}
121 changes: 121 additions & 0 deletions internal/instance/lock_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//go:build unix

package instance

import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
)

func TestLockHelperProcess(t *testing.T) {
if os.Getenv("CLIAMP_LOCK_HELPER") != "1" {
return
}
l, err := Acquire(false)
if err != nil {
fmt.Printf("acquire error: %v\n", err)
os.Exit(2)
}
defer l.Close()
fmt.Println("ready")
for {
time.Sleep(5 * time.Second)
}
}

func TestAcquireAndTakeover(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

cmd := exec.Command(os.Args[0], "-test.run=TestLockHelperProcess")
cmd.Env = append(os.Environ(),
"CLIAMP_LOCK_HELPER=1",
"HOME="+home,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("StdoutPipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start helper: %v", err)
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()

sc := bufio.NewScanner(stdout)
deadline := time.Now().Add(5 * time.Second)
ready := false
for time.Now().Before(deadline) {
if sc.Scan() && sc.Text() == "ready" {
ready = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !ready {
t.Fatalf("helper did not become ready")
}

_, err = Acquire(false)
var le LockedError
if err == nil || !errors.As(err, &le) || le.PID <= 0 {
t.Fatalf("Acquire(false) err = %v, want LockedError with pid", err)
}

l, err := Acquire(true)
if err != nil {
t.Fatalf("Acquire(true): %v", err)
}
defer l.Close()

waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case <-time.After(5 * time.Second):
t.Fatalf("helper process still alive after takeover")
case <-waitCh:
}

// Lock file should contain our own pid now.
data, err := os.ReadFile(filepath.Join(home, ".config", "cliamp", "session.lock"))
if err != nil {
t.Fatalf("Read lock file: %v", err)
}
if string(data) == "" {
t.Fatalf("lock file is empty after takeover")
}
}

func TestAcquireLockedErrorIncludesPIDFromFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

lockPath := filepath.Join(home, ".config", "cliamp", "session.lock")
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
t.Fatalf("OpenFile: %v", err)
}
defer f.Close()
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
t.Fatalf("Flock: %v", err)
}
_, _ = f.WriteString("12345\n")

_, err = Acquire(false)
var le LockedError
if !errors.As(err, &le) || le.PID != 12345 {
t.Fatalf("Acquire(false) err = %v, want LockedError{PID:12345}", err)
}
}
Loading