Skip to content

Commit cd4ff85

Browse files
committed
Refactor resume to session queue model with autosave and takeover lock
1 parent ff5387e commit cd4ff85

File tree

13 files changed

+716
-32
lines changed

13 files changed

+716
-32
lines changed

config.toml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ shuffle = false
1313
# Start with mono output (L+R downmix)
1414
mono = false
1515

16+
# Restore last playlist/track/position when reopening with no CLI args
17+
resume_session = true
18+
1619
# Shift+Left/Right seek jump in seconds (6-600)
1720
seek_large_step_sec = 30
1821

config/config.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string
108108

109109
// Config holds user preferences loaded from the config file.
110110
type Config struct {
111-
Volume float64 // dB, range [-30, +6]
112-
EQ [10]float64 // per-band gain in dB, range [-12, +12]
113-
EQPreset string // preset name, or "" for custom
114-
Repeat string // "off", "all", or "one"
111+
Volume float64 // dB, range [-30, +6]
112+
EQ [10]float64 // per-band gain in dB, range [-12, +12]
113+
EQPreset string // preset name, or "" for custom
114+
Repeat string // "off", "all", or "one"
115115
Shuffle bool
116116
Mono bool
117117
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
@@ -123,6 +123,7 @@ type Config struct {
123123
ResampleQuality int // beep resample quality factor (1–4)
124124
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32
125125
Compact bool // compact mode: cap frame width at 80 columns
126+
ResumeSession bool // restore last playlist/index/position on startup when no args
126127
Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials
127128
Spotify SpotifyConfig // optional Spotify provider (requires Premium)
128129
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
@@ -136,6 +137,7 @@ func Default() Config {
136137
return Config{
137138
Repeat: "off",
138139
SeekStepLarge: 30,
140+
ResumeSession: true,
139141
SampleRate: 0,
140142
BufferMs: 100,
141143
ResampleQuality: 4,
@@ -271,6 +273,8 @@ func Load() (Config, error) {
271273
}
272274
case "compact":
273275
cfg.Compact = val == "true"
276+
case "resume_session":
277+
cfg.ResumeSession = val == "true"
274278
}
275279
}
276280
}

config/flags.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Overrides struct {
2222
BitDepth *int
2323
Play *bool
2424
Compact *bool
25+
Takeover *bool
2526
}
2627

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

109112
// Key-value flags.
110113
case "--provider":

docs/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ shuffle = false
2222
# Start with mono output (L+R downmix)
2323
mono = false
2424

25+
# Restore last playlist/track/position when reopening with no CLI args
26+
resume_session = true
27+
2528
# Shift+Left/Right seek jump in seconds
2629
seek_large_step_sec = 30
2730

internal/instance/lock_nonunix.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//go:build !unix
2+
3+
package instance
4+
5+
// Lock is a no-op on non-unix platforms.
6+
type Lock struct{}
7+
8+
// Acquire is a no-op on non-unix platforms.
9+
func Acquire(takeover bool) (*Lock, error) { return &Lock{}, nil }
10+
11+
func (l *Lock) Close() {}

internal/instance/lock_unix.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//go:build unix
2+
3+
package instance
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
"time"
14+
15+
"cliamp/internal/appdir"
16+
)
17+
18+
const (
19+
takeoverPollInterval = 100 * time.Millisecond
20+
takeoverTimeout = 8 * time.Second
21+
takeoverKillAfter = 3 * time.Second
22+
)
23+
24+
type Lock struct {
25+
f *os.File
26+
}
27+
28+
// LockedError reports that another cliamp instance currently holds the lock.
29+
type LockedError struct {
30+
PID int
31+
}
32+
33+
func (e LockedError) Error() string {
34+
if e.PID > 0 {
35+
return fmt.Sprintf("another cliamp instance is running (pid %d). Re-run with --takeover to stop it.", e.PID)
36+
}
37+
return "another cliamp instance is running. Re-run with --takeover to stop it."
38+
}
39+
40+
func lockFile() (string, error) {
41+
dir, err := appdir.Dir()
42+
if err != nil {
43+
return "", err
44+
}
45+
return filepath.Join(dir, "session.lock"), nil
46+
}
47+
48+
func readPID(f *os.File) int {
49+
if _, err := f.Seek(0, 0); err != nil {
50+
return 0
51+
}
52+
data, err := os.ReadFile(f.Name())
53+
if err != nil {
54+
return 0
55+
}
56+
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
57+
return pid
58+
}
59+
60+
func writePID(f *os.File, pid int) {
61+
if err := f.Truncate(0); err != nil {
62+
return
63+
}
64+
if _, err := f.Seek(0, 0); err != nil {
65+
return
66+
}
67+
_, _ = fmt.Fprintf(f, "%d\n", pid)
68+
}
69+
70+
func tryLock(f *os.File) error {
71+
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
72+
}
73+
74+
// Acquire obtains a process lock for cliamp.
75+
// If takeover is true, it sends SIGTERM to the lock holder and retries.
76+
func Acquire(takeover bool) (*Lock, error) {
77+
path, err := lockFile()
78+
if err != nil {
79+
return nil, err
80+
}
81+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
82+
return nil, err
83+
}
84+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
85+
if err != nil {
86+
return nil, err
87+
}
88+
lockNow := func() error {
89+
err := tryLock(f)
90+
if err == nil {
91+
writePID(f, os.Getpid())
92+
return nil
93+
}
94+
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
95+
return err
96+
}
97+
return err
98+
}
99+
100+
if err := lockNow(); err == nil {
101+
return &Lock{f: f}, nil
102+
} else if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
103+
_ = f.Close()
104+
return nil, err
105+
}
106+
107+
pid := readPID(f)
108+
if !takeover {
109+
_ = f.Close()
110+
return nil, LockedError{PID: pid}
111+
}
112+
113+
if pid > 0 && pid != os.Getpid() {
114+
if p, err := os.FindProcess(pid); err == nil {
115+
_ = p.Signal(syscall.SIGTERM)
116+
}
117+
}
118+
start := time.Now()
119+
deadline := start.Add(takeoverTimeout)
120+
sentKill := false
121+
for time.Now().Before(deadline) {
122+
time.Sleep(takeoverPollInterval)
123+
if err := lockNow(); err == nil {
124+
return &Lock{f: f}, nil
125+
}
126+
if !sentKill && pid > 0 && time.Since(start) >= takeoverKillAfter {
127+
if p, err := os.FindProcess(pid); err == nil {
128+
_ = p.Signal(syscall.SIGKILL)
129+
}
130+
sentKill = true
131+
}
132+
}
133+
_ = f.Close()
134+
return nil, fmt.Errorf("failed to take over running instance (pid %d)", pid)
135+
}
136+
137+
func (l *Lock) Close() {
138+
if l == nil || l.f == nil {
139+
return
140+
}
141+
_ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN)
142+
_ = l.f.Close()
143+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//go:build unix
2+
3+
package instance
4+
5+
import (
6+
"bufio"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"syscall"
13+
"testing"
14+
"time"
15+
)
16+
17+
func TestLockHelperProcess(t *testing.T) {
18+
if os.Getenv("CLIAMP_LOCK_HELPER") != "1" {
19+
return
20+
}
21+
l, err := Acquire(false)
22+
if err != nil {
23+
fmt.Printf("acquire error: %v\n", err)
24+
os.Exit(2)
25+
}
26+
defer l.Close()
27+
fmt.Println("ready")
28+
for {
29+
time.Sleep(5 * time.Second)
30+
}
31+
}
32+
33+
func TestAcquireAndTakeover(t *testing.T) {
34+
home := t.TempDir()
35+
t.Setenv("HOME", home)
36+
37+
cmd := exec.Command(os.Args[0], "-test.run=TestLockHelperProcess")
38+
cmd.Env = append(os.Environ(),
39+
"CLIAMP_LOCK_HELPER=1",
40+
"HOME="+home,
41+
)
42+
stdout, err := cmd.StdoutPipe()
43+
if err != nil {
44+
t.Fatalf("StdoutPipe: %v", err)
45+
}
46+
if err := cmd.Start(); err != nil {
47+
t.Fatalf("Start helper: %v", err)
48+
}
49+
defer func() {
50+
_ = cmd.Process.Kill()
51+
_, _ = cmd.Process.Wait()
52+
}()
53+
54+
sc := bufio.NewScanner(stdout)
55+
deadline := time.Now().Add(5 * time.Second)
56+
ready := false
57+
for time.Now().Before(deadline) {
58+
if sc.Scan() && sc.Text() == "ready" {
59+
ready = true
60+
break
61+
}
62+
time.Sleep(20 * time.Millisecond)
63+
}
64+
if !ready {
65+
t.Fatalf("helper did not become ready")
66+
}
67+
68+
_, err = Acquire(false)
69+
var le LockedError
70+
if err == nil || !errors.As(err, &le) || le.PID <= 0 {
71+
t.Fatalf("Acquire(false) err = %v, want LockedError with pid", err)
72+
}
73+
74+
l, err := Acquire(true)
75+
if err != nil {
76+
t.Fatalf("Acquire(true): %v", err)
77+
}
78+
defer l.Close()
79+
80+
waitCh := make(chan error, 1)
81+
go func() { waitCh <- cmd.Wait() }()
82+
select {
83+
case <-time.After(5 * time.Second):
84+
t.Fatalf("helper process still alive after takeover")
85+
case <-waitCh:
86+
}
87+
88+
// Lock file should contain our own pid now.
89+
data, err := os.ReadFile(filepath.Join(home, ".config", "cliamp", "session.lock"))
90+
if err != nil {
91+
t.Fatalf("Read lock file: %v", err)
92+
}
93+
if string(data) == "" {
94+
t.Fatalf("lock file is empty after takeover")
95+
}
96+
}
97+
98+
func TestAcquireLockedErrorIncludesPIDFromFile(t *testing.T) {
99+
home := t.TempDir()
100+
t.Setenv("HOME", home)
101+
102+
lockPath := filepath.Join(home, ".config", "cliamp", "session.lock")
103+
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
104+
t.Fatalf("MkdirAll: %v", err)
105+
}
106+
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
107+
if err != nil {
108+
t.Fatalf("OpenFile: %v", err)
109+
}
110+
defer f.Close()
111+
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
112+
t.Fatalf("Flock: %v", err)
113+
}
114+
_, _ = f.WriteString("12345\n")
115+
116+
_, err = Acquire(false)
117+
var le LockedError
118+
if !errors.As(err, &le) || le.PID != 12345 {
119+
t.Fatalf("Acquire(false) err = %v, want LockedError{PID:12345}", err)
120+
}
121+
}

0 commit comments

Comments
 (0)