podcast: stop the player on ctrl-c.

nak only waited for the player with cmd.Run() and never handled interrupts
itself. on windows the console ctrl-c event reaches every process attached to
the console, but mpv.exe is a gui subsystem binary that installs no console
handler (mpv.com is the console wrapper), so the player kept playing while nak
went away.

handle the interrupt ourselves and kill the player, killing the whole process
tree with taskkill on windows so helper processes go away too. also drop the
hardcoded .exe from the windows candidates: LookPath honours PATHEXT, so
mpv.com is picked when it is around.

Claude-Session: https://claude.ai/code/session_01GvbaFeAfu16pDBy6ridwop
This commit is contained in:
Yasuhiro Matsumoto
2026-09-07 13:13:22 +09:00
parent 669816098f
commit eadeb62ae0

View File

@@ -2,11 +2,17 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
@@ -63,7 +69,7 @@ var podcast = &cli.Command{
return err
}
return playPodcastEpisode(c.String("player"), episode)
return playPodcastEpisode(ctx, c.String("player"), episode)
}
for target := range getStdinLinesOrArguments(c.Args()) {
@@ -81,7 +87,7 @@ var podcast = &cli.Command{
return err
}
if err := playPodcastEpisode(c.String("player"), episode); err != nil {
if err := playPodcastEpisode(ctx, c.String("player"), episode); err != nil {
return err
}
}
@@ -462,7 +468,7 @@ func fetchLatestPodcastEpisode(ctx context.Context, podcast podcastInfo) (*nostr
return latest, nil
}
func playPodcastEpisode(player string, episode *nostr.Event) error {
func playPodcastEpisode(ctx context.Context, player string, episode *nostr.Event) error {
audioURL := firstPodcastAudioURL(*episode)
if audioURL == "" {
return fmt.Errorf("podcast episode has no audio tag")
@@ -484,7 +490,61 @@ func playPodcastEpisode(player string, episode *nostr.Event) error {
cmd.Stdout = color.Output
cmd.Stderr = color.Error
return cmd.Run()
return runPodcastPlayer(ctx, cmd)
}
// errPodcastInterrupted is returned when the user stops playback with ctrl-c.
var errPodcastInterrupted = errors.New("interrupted")
// runPodcastPlayer runs the player and makes sure it goes away when we do.
// on windows a ctrl-c doesn't necessarily reach the player (mpv.exe is a gui
// subsystem binary and never installs a console handler), so instead of relying
// on the console sending it the event we catch the interrupt ourselves and kill
// the player.
func runPodcastPlayer(ctx context.Context, cmd *exec.Cmd) error {
if err := cmd.Start(); err != nil {
return err
}
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(interrupt)
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
return err
case <-interrupt:
case <-ctx.Done():
}
killPodcastPlayer(cmd)
// give the player a moment to die so it doesn't keep writing to our terminal
select {
case <-done:
case <-time.After(3 * time.Second):
}
return errPodcastInterrupted
}
func killPodcastPlayer(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
if runtime.GOOS == "windows" {
// players tend to spawn helper processes, so kill the whole tree
kill := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid))
if err := kill.Run(); err == nil {
return
}
}
cmd.Process.Kill()
}
func buildPodcastPlayerCommand(player string, audioURL string) (*exec.Cmd, error) {
@@ -526,9 +586,11 @@ func podcastPlayerCandidates() []struct {
name string
args []string
}{
{"mpv.exe", nil},
{"vlc.exe", nil},
{"ffplay.exe", []string{"-nodisp", "-autoexit"}},
// no .exe here on purpose: LookPath honours PATHEXT, so mpv.com,
// the console wrapper that handles ctrl-c, is preferred when present
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"cmd", []string{"/c", "start"}},
}
}