Skip to content

Commit 49623aa

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

12 files changed

Lines changed: 509 additions & 31 deletions

File tree

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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
type Lock struct {
19+
f *os.File
20+
}
21+
22+
// LockedError reports that another cliamp instance currently holds the lock.
23+
type LockedError struct {
24+
PID int
25+
}
26+
27+
func (e LockedError) Error() string {
28+
if e.PID > 0 {
29+
return fmt.Sprintf("another cliamp instance is running (pid %d). Re-run with --takeover to stop it.", e.PID)
30+
}
31+
return "another cliamp instance is running. Re-run with --takeover to stop it."
32+
}
33+
34+
func lockFile() (string, error) {
35+
dir, err := appdir.Dir()
36+
if err != nil {
37+
return "", err
38+
}
39+
return filepath.Join(dir, "session.lock"), nil
40+
}
41+
42+
func readPID(f *os.File) int {
43+
if _, err := f.Seek(0, 0); err != nil {
44+
return 0
45+
}
46+
data, err := os.ReadFile(f.Name())
47+
if err != nil {
48+
return 0
49+
}
50+
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
51+
return pid
52+
}
53+
54+
func writePID(f *os.File, pid int) {
55+
if err := f.Truncate(0); err != nil {
56+
return
57+
}
58+
if _, err := f.Seek(0, 0); err != nil {
59+
return
60+
}
61+
_, _ = fmt.Fprintf(f, "%d\n", pid)
62+
}
63+
64+
func tryLock(f *os.File) error {
65+
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
66+
}
67+
68+
// Acquire obtains a process lock for cliamp.
69+
// If takeover is true, it sends SIGTERM to the lock holder and retries.
70+
func Acquire(takeover bool) (*Lock, error) {
71+
path, err := lockFile()
72+
if err != nil {
73+
return nil, err
74+
}
75+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
76+
return nil, err
77+
}
78+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
79+
if err != nil {
80+
return nil, err
81+
}
82+
lockNow := func() error {
83+
err := tryLock(f)
84+
if err == nil {
85+
writePID(f, os.Getpid())
86+
return nil
87+
}
88+
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
89+
return err
90+
}
91+
return err
92+
}
93+
94+
if err := lockNow(); err == nil {
95+
return &Lock{f: f}, nil
96+
} else if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
97+
_ = f.Close()
98+
return nil, err
99+
}
100+
101+
pid := readPID(f)
102+
if !takeover {
103+
_ = f.Close()
104+
return nil, LockedError{PID: pid}
105+
}
106+
107+
if pid > 0 && pid != os.Getpid() {
108+
if p, err := os.FindProcess(pid); err == nil {
109+
_ = p.Signal(syscall.SIGTERM)
110+
}
111+
}
112+
deadline := time.Now().Add(3 * time.Second)
113+
for time.Now().Before(deadline) {
114+
time.Sleep(100 * time.Millisecond)
115+
if err := lockNow(); err == nil {
116+
return &Lock{f: f}, nil
117+
}
118+
}
119+
_ = f.Close()
120+
return nil, fmt.Errorf("failed to take over running instance (pid %d)", pid)
121+
}
122+
123+
func (l *Lock) Close() {
124+
if l == nil || l.f == nil {
125+
return
126+
}
127+
_ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN)
128+
_ = l.f.Close()
129+
}

0 commit comments

Comments
 (0)