From eadeb62ae0f21ac4fa8d952dafaf8ea3d194bdbd Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Mon, 7 Sep 2026 13:13:22 +0900 Subject: [PATCH] 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 --- podcast.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/podcast.go b/podcast.go index 0ed034b..f567ee6 100644 --- a/podcast.go +++ b/podcast.go @@ -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"}}, } }