Skip to content

Commit c76ce91

Browse files
committed
fix: isolate mpv startup from user config
1 parent 62692c1 commit c76ce91

5 files changed

Lines changed: 240 additions & 76 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ curl -fsSL https://raw.githubusercontent.com/iRootPro/lofi-player/main/scripts/i
7575

7676
Auto-detects OS/arch, pulls the matching tarball from the latest
7777
release, drops the binary into `~/.local/bin`. Override with
78-
`INSTALL_DIR=/usr/local/bin` or pin to a tag with `VERSION=v0.1.3`.
78+
`INSTALL_DIR=/usr/local/bin` or pin to a tag with `VERSION=v0.1.4`.
7979

8080
You'll also need `mpv` (and optionally `yt-dlp`) — see
8181
[runtime dependencies](#runtime-dependencies) below. The installer

README.ru.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ curl -fsSL https://raw.githubusercontent.com/iRootPro/lofi-player/main/scripts/i
7979

8080
Сам определяет OS/arch, скачивает архив из последнего релиза, кладёт
8181
бинарник в `~/.local/bin`. Переопределить можно через
82-
`INSTALL_DIR=/usr/local/bin`, заПинить версию — через `VERSION=v0.1.3`.
82+
`INSTALL_DIR=/usr/local/bin`, заПинить версию — через `VERSION=v0.1.4`.
8383

8484
`mpv` (и опционально `yt-dlp`) поставь отдельно — см.
8585
[системные зависимости](#системные-зависимости) ниже. Инсталлер

internal/audio/ambient_player.go

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package audio
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"os"
@@ -12,6 +13,7 @@ import (
1213

1314
type ambientPlayer struct {
1415
cmd *exec.Cmd
16+
exited *processWaiter
1517
ipc *ipcClient
1618
socketDir string
1719
closeOnce sync.Once
@@ -27,42 +29,29 @@ func newAmbientPlayer(ctx context.Context, filePath string) (*ambientPlayer, err
2729
}
2830
socketPath := filepath.Join(socketDir, "mpv.sock")
2931

30-
cmd := exec.Command("mpv",
31-
"--idle=no",
32-
"--no-video",
33-
"--no-terminal",
34-
"--really-quiet",
35-
"--loop-file=inf",
36-
"--volume=0",
37-
"--pause=yes",
38-
"--input-ipc-server="+socketPath,
39-
filePath,
40-
)
32+
var stderr bytes.Buffer
33+
cmd := exec.Command("mpv", ambientMPVArgs(socketPath, filePath)...)
34+
cmd.Stderr = &stderr
4135
if err := cmd.Start(); err != nil {
4236
os.RemoveAll(socketDir)
4337
return nil, fmt.Errorf("start mpv: %w", err)
4438
}
4539

46-
exited := make(chan error, 1)
47-
go func() { exited <- cmd.Wait() }()
40+
exited := newProcessWaiter(cmd)
4841

4942
if err := waitForSocketOrExit(ctx, socketPath, 5*time.Second, exited); err != nil {
50-
// waitForSocketOrExit may have consumed the exit value from `exited`
51-
// when mpv died early, so a follow-up <-exited would block forever.
52-
// Best-effort kill is enough; the goroutine sends to a buffered
53-
// channel (size 1) and exits cleanly once cmd.Wait returns.
54-
_ = cmd.Process.Kill()
43+
terminateMPVProcess(cmd, exited)
5544
os.RemoveAll(socketDir)
56-
return nil, fmt.Errorf("mpv socket did not appear: %w", err)
45+
return nil, formatMPVStartupError(err, stderr.String(), cmd.Args)
5746
}
5847

5948
ipc, err := dialIPC(socketPath)
6049
if err != nil {
61-
_ = cmd.Process.Kill()
50+
terminateMPVProcess(cmd, exited)
6251
os.RemoveAll(socketDir)
6352
return nil, fmt.Errorf("dial mpv: %w", err)
6453
}
65-
return &ambientPlayer{cmd: cmd, ipc: ipc, socketDir: socketDir}, nil
54+
return &ambientPlayer{cmd: cmd, exited: exited, ipc: ipc, socketDir: socketDir}, nil
6655
}
6756

6857
func (p *ambientPlayer) setVolume(v int) error {
@@ -87,16 +76,7 @@ func (p *ambientPlayer) close() {
8776
cancel()
8877
_ = p.ipc.close()
8978
}
90-
if p.cmd != nil && p.cmd.Process != nil {
91-
done := make(chan struct{})
92-
go func() { _ = p.cmd.Wait(); close(done) }()
93-
select {
94-
case <-done:
95-
case <-time.After(2 * time.Second):
96-
_ = p.cmd.Process.Kill()
97-
<-done
98-
}
99-
}
79+
waitForMPVProcessExit(p.cmd, p.exited, 2*time.Second)
10080
if p.socketDir != "" {
10181
_ = os.RemoveAll(p.socketDir)
10282
}

internal/audio/player.go

Lines changed: 140 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ type Options struct {
103103
// process exits unexpectedly.
104104
type Player struct {
105105
cmd *exec.Cmd
106+
exited *processWaiter
106107
ipc *ipcClient
107108
socketDir string
108109
events chan Event
@@ -123,6 +124,30 @@ type Player struct {
123124
lastCacheSec float64
124125
}
125126

127+
func mainMPVArgs(socketPath string) []string {
128+
return []string{
129+
"--no-config",
130+
"--idle=yes",
131+
"--no-video",
132+
"--no-terminal",
133+
"--input-ipc-server=" + socketPath,
134+
}
135+
}
136+
137+
func ambientMPVArgs(socketPath, filePath string) []string {
138+
return []string{
139+
"--no-config",
140+
"--idle=no",
141+
"--no-video",
142+
"--no-terminal",
143+
"--loop-file=inf",
144+
"--volume=0",
145+
"--pause=yes",
146+
"--input-ipc-server=" + socketPath,
147+
filePath,
148+
}
149+
}
150+
126151
// NewPlayer spawns mpv in idle mode and establishes a JSON-IPC connection
127152
// to it. The returned Player is ready to accept Play/Pause/Resume calls.
128153
//
@@ -149,52 +174,31 @@ func NewPlayer(ctx context.Context, opts Options) (*Player, error) {
149174
socketPath := filepath.Join(socketDir, "mpv.sock")
150175

151176
var stderr bytes.Buffer
152-
cmd := exec.Command(mpvPath,
153-
"--idle=yes",
154-
"--no-video",
155-
"--no-terminal",
156-
"--really-quiet",
157-
"--input-ipc-server="+socketPath,
158-
)
177+
cmd := exec.Command(mpvPath, mainMPVArgs(socketPath)...)
159178
cmd.Stderr = &stderr
160179
if err := cmd.Start(); err != nil {
161180
os.RemoveAll(socketDir)
162181
return nil, fmt.Errorf("start mpv: %w", err)
163182
}
164183

165-
exited := make(chan error, 1)
166-
go func() { exited <- cmd.Wait() }()
184+
exited := newProcessWaiter(cmd)
167185

168186
if err := waitForSocketOrExit(ctx, socketPath, 5*time.Second, exited); err != nil {
169-
// If mpv is still alive, terminate it; otherwise it already exited.
170-
select {
171-
case <-exited:
172-
default:
173-
_ = cmd.Process.Kill()
174-
<-exited
175-
}
187+
terminateMPVProcess(cmd, exited)
176188
os.RemoveAll(socketDir)
177-
stderrSnippet := strings.TrimSpace(stderr.String())
178-
if stderrSnippet != "" {
179-
return nil, fmt.Errorf("mpv did not open IPC socket: %w; mpv stderr: %s", err, stderrSnippet)
180-
}
181-
return nil, fmt.Errorf("mpv did not open IPC socket: %w", err)
189+
return nil, formatMPVStartupError(err, stderr.String(), cmd.Args)
182190
}
183191

184192
ipc, err := dialIPC(socketPath)
185193
if err != nil {
186-
select {
187-
case <-exited:
188-
default:
189-
_ = cmd.Process.Kill()
190-
<-exited
191-
}
194+
terminateMPVProcess(cmd, exited)
192195
os.RemoveAll(socketDir)
193196
return nil, fmt.Errorf("dial mpv: %w", err)
194197
}
195198

196199
p := &Player{
197200
cmd: cmd,
201+
exited: exited,
198202
ipc: ipc,
199203
socketDir: socketDir,
200204
events: make(chan Event, 32),
@@ -300,19 +304,7 @@ func (p *Player) Close() error {
300304
cancel()
301305
_ = p.ipc.close()
302306
}
303-
if p.cmd != nil && p.cmd.Process != nil {
304-
done := make(chan struct{})
305-
go func() {
306-
_ = p.cmd.Wait()
307-
close(done)
308-
}()
309-
select {
310-
case <-done:
311-
case <-time.After(2 * time.Second):
312-
_ = p.cmd.Process.Kill()
313-
<-done
314-
}
315-
}
307+
waitForMPVProcessExit(p.cmd, p.exited, 2*time.Second)
316308
if p.socketDir != "" {
317309
_ = os.RemoveAll(p.socketDir)
318310
}
@@ -512,19 +504,124 @@ func clampVolume(v int) int {
512504
}
513505
}
514506

507+
type processWaiter struct {
508+
done chan struct{}
509+
err error
510+
}
511+
512+
func newProcessWaiter(cmd *exec.Cmd) *processWaiter {
513+
w := &processWaiter{done: make(chan struct{})}
514+
go func() {
515+
w.err = cmd.Wait()
516+
close(w.done)
517+
}()
518+
return w
519+
}
520+
521+
func (w *processWaiter) Done() <-chan struct{} {
522+
if w == nil {
523+
return nil
524+
}
525+
return w.done
526+
}
527+
528+
func (w *processWaiter) Err() error {
529+
if w == nil {
530+
return nil
531+
}
532+
<-w.done
533+
return w.err
534+
}
535+
536+
func terminateMPVProcess(cmd *exec.Cmd, exited *processWaiter) {
537+
if cmd == nil || cmd.Process == nil {
538+
return
539+
}
540+
select {
541+
case <-exited.Done():
542+
return
543+
default:
544+
}
545+
_ = cmd.Process.Kill()
546+
waitForMPVProcessExit(cmd, exited, 2*time.Second)
547+
}
548+
549+
func waitForMPVProcessExit(cmd *exec.Cmd, exited *processWaiter, timeout time.Duration) {
550+
if cmd == nil || cmd.Process == nil {
551+
return
552+
}
553+
if exited == nil {
554+
done := make(chan struct{})
555+
go func() {
556+
_ = cmd.Wait()
557+
close(done)
558+
}()
559+
select {
560+
case <-done:
561+
case <-time.After(timeout):
562+
_ = cmd.Process.Kill()
563+
<-done
564+
}
565+
return
566+
}
567+
568+
select {
569+
case <-exited.Done():
570+
return
571+
case <-time.After(timeout):
572+
_ = cmd.Process.Kill()
573+
}
574+
select {
575+
case <-exited.Done():
576+
case <-time.After(500 * time.Millisecond):
577+
}
578+
}
579+
580+
func formatMPVStartupError(err error, stderr string, args []string) error {
581+
details := make([]string, 0, 3)
582+
if snippet := trimForError(stderr, 2000); snippet != "" {
583+
details = append(details, "mpv stderr: "+snippet)
584+
}
585+
if len(args) > 0 {
586+
details = append(details, "command: "+formatCommandForError(args))
587+
}
588+
details = append(details, "hint: lofi-player starts mpv with --no-config to avoid user mpv.conf/scripts; try `mpv --no-config --idle=yes --no-video --no-terminal --input-ipc-server=/tmp/lofi-test.sock` to diagnose mpv itself")
589+
return fmt.Errorf("mpv did not open IPC socket: %w; %s", err, strings.Join(details, "; "))
590+
}
591+
592+
func trimForError(s string, max int) string {
593+
s = strings.TrimSpace(s)
594+
if max <= 0 || len(s) <= max {
595+
return s
596+
}
597+
return s[:max] + "…"
598+
}
599+
600+
func formatCommandForError(args []string) string {
601+
formatted := make([]string, 0, len(args))
602+
for _, arg := range args {
603+
if strings.ContainsAny(arg, " \t\n\"'") {
604+
formatted = append(formatted, fmt.Sprintf("%q", arg))
605+
continue
606+
}
607+
formatted = append(formatted, arg)
608+
}
609+
return strings.Join(formatted, " ")
610+
}
611+
515612
// waitForSocketOrExit polls for the socket file to appear, returning
516613
// early if mpv exits beforehand (in which case the error wraps mpv's
517614
// exit status — usually a nil exit means "exited cleanly without error
518615
// but never opened the socket", which still counts as failure here).
519-
func waitForSocketOrExit(ctx context.Context, path string, timeout time.Duration, exited <-chan error) error {
616+
func waitForSocketOrExit(ctx context.Context, path string, timeout time.Duration, exited *processWaiter) error {
520617
deadline := time.Now().Add(timeout)
521618
for time.Now().Before(deadline) {
522619
if _, err := os.Stat(path); err == nil {
523620
return nil
524621
}
525622
select {
526-
case waitErr := <-exited:
527-
if waitErr != nil {
623+
case <-exited.Done():
624+
if waitErr := exited.Err(); waitErr != nil {
528625
return fmt.Errorf("mpv exited prematurely: %w", waitErr)
529626
}
530627
return errors.New("mpv exited prematurely with status 0")

0 commit comments

Comments
 (0)