aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/external-open.el22
-rw-r--r--modules/media-utils.el97
-rw-r--r--tests/test-external-open--open-with-argv.el59
-rw-r--r--tests/test-external-open-commands.el29
-rw-r--r--tests/test-media-utils--argv.el72
-rw-r--r--tests/test-media-utils.el72
6 files changed, 284 insertions, 67 deletions
diff --git a/modules/external-open.el b/modules/external-open.el
index 811c32c2..306db94f 100644
--- a/modules/external-open.el
+++ b/modules/external-open.el
@@ -142,6 +142,18 @@ Logs output and exit code to buffer *external-open.log*."
;; ------------------------------- Open File With ------------------------------
+(defun cj/--open-with-argv (command file)
+ "The argv list to open FILE with the user-typed COMMAND.
+COMMAND may carry arguments (\"mpv --fs\"); `split-string-and-unquote'
+splits it so a double-quoted argument survives as one word. FILE is
+appended as the final element, so paths with spaces or shell
+metacharacters never meet a shell. Signals a `user-error' when COMMAND
+is empty or whitespace."
+ (let ((argv (split-string-and-unquote command)))
+ (unless argv
+ (user-error "No program given"))
+ (append argv (list file))))
+
(defun cj/open-this-file-with (command)
"Open this buffer's file with COMMAND, detached from Emacs."
(interactive "MOpen with program: ")
@@ -152,12 +164,12 @@ Logs output and exit code to buffer *external-open.log*."
;; Windows: launch via ShellExecute so the child isn't tied to Emacs.
((env-windows-p)
(w32-shell-execute "open" command (format "\"%s\"" file)))
- ;; POSIX: disown with nohup + background. No child remains.
+ ;; POSIX: argv launch, DESTINATION 0 detaches with no shell in between.
(t
- (call-process-shell-command
- (format "nohup %s %s >/dev/null 2>&1 &"
- command (shell-quote-argument file))
- nil 0)))))
+ (let ((argv (cj/--open-with-argv command file)))
+ (unless (executable-find (car argv))
+ (user-error "Program not found: %s" (car argv)))
+ (apply #'call-process (car argv) nil 0 nil (cdr argv)))))))
;; -------------------------- Open Videos On Repeat ----------------------------
diff --git a/modules/media-utils.el b/modules/media-utils.el
index 1abbc1b2..e3f4022f 100644
--- a/modules/media-utils.el
+++ b/modules/media-utils.el
@@ -119,8 +119,54 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
;; ---------------------- Playing Via Default Media Player ---------------------
+(defun cj/media--yt-dlp-argv (url formats)
+ "The argv to resolve URL's stream address: yt-dlp [-f FORMATS] -g URL.
+FORMATS is a prioritized list of yt-dlp format codes, or nil for the
+default. URL stays one verbatim argv element, so it never meets a shell."
+ (append (list "yt-dlp")
+ (when formats (list "-f" (string-join formats "/")))
+ (list "-g" url)))
+
+(defun cj/media--stream-urls (output)
+ "The non-empty lines of yt-dlp -g OUTPUT, surrounding whitespace trimmed."
+ (split-string output "\n" t "[ \t\r]+"))
+
+(defun cj/media--play-argv (command args urls)
+ "The argv to play URLS with COMMAND.
+ARGS is the player's raw option string from `cj/media-players' (nil for
+none); it splits with `split-string-and-unquote' so a quoted option
+survives as one word."
+ (append (list command)
+ (and args (split-string-and-unquote args))
+ urls))
+
+(defun cj/media--resolve-stream-urls (url formats)
+ "Resolve URL to direct stream URLs with a synchronous yt-dlp -g capture.
+FORMATS is the player's format-preference list. Only stdout is parsed
+for URLs -- yt-dlp's warnings go to stderr, captured separately for the
+error message. Signals an error when yt-dlp exits non-zero or resolves
+nothing."
+ (let ((err-file (make-temp-file "yt-dlp-stderr")))
+ (unwind-protect
+ (with-temp-buffer
+ (let* ((argv (cj/media--yt-dlp-argv url formats))
+ (exit (apply #'call-process (car argv) nil
+ (list t err-file) nil (cdr argv))))
+ (unless (and (integerp exit) (zerop exit))
+ (error "yt-dlp failed (exit %s): %s" exit
+ (string-trim
+ (with-temp-buffer
+ (insert-file-contents err-file)
+ (buffer-string)))))
+ (or (cj/media--stream-urls (buffer-string))
+ (error "yt-dlp resolved no stream URL for %s" url))))
+ (delete-file err-file))))
+
(defun cj/media-play-it (url)
- "Play the URL with the configured media player in an async process."
+ "Play the URL with the configured media player in an async process.
+A player flagged :needs-stream-url gets the URL resolved first via a
+synchronous yt-dlp -g capture (blocks briefly); the player then launches
+with a plain argv list -- no shell anywhere in the pipeline."
(let* ((player-config (alist-get cj/default-media-player cj/media-players))
(command (plist-get player-config :command))
(args (plist-get player-config :args))
@@ -131,36 +177,22 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
(unless (executable-find command)
(error "%s is not installed or not in PATH" player-name))
-
- (let* ((buffer-name (format "*%s: %s*" player-name url-display))
- (shell-command
- (if needs-stream-url
- ;; Use shell substitution with yt-dlp
- (let ((format-string (if yt-dlp-formats
- (format "-f %s"
- (mapconcat #'shell-quote-argument
- yt-dlp-formats
- "/"))
- "")))
- (format "%s %s $(%s %s -g %s)"
- command
- (or args "")
- "yt-dlp"
- format-string
- (shell-quote-argument url)))
- ;; Direct playback without yt-dlp
- (format "%s %s %s"
- command
- (or args "")
- (shell-quote-argument url)))))
+ (when needs-stream-url
+ (unless (executable-find "yt-dlp")
+ (error "The program yt-dlp is not installed or not in PATH")))
+
+ (let* ((urls (if needs-stream-url
+ (progn
+ (message "Resolving stream URL: %s" url-display)
+ (cj/media--resolve-stream-urls url yt-dlp-formats))
+ (list url)))
+ (argv (cj/media--play-argv command args urls))
+ (buffer-name (format "*%s: %s*" player-name url-display)))
(message "Playing with %s: %s" player-name url-display)
- (cj/log-silently "DEBUG: Executing: %s" shell-command)
+ (cj/log-silently "DEBUG: Executing: %s" (string-join argv " "))
- (let ((process (start-process-shell-command
- player-name
- buffer-name
- shell-command)))
+ (let ((process (apply #'start-process player-name buffer-name argv)))
(set-process-sentinel
process
(lambda (proc event)
@@ -168,14 +200,7 @@ stream URL (see the :needs-stream-url flag in `cj/media-players')."
((string-match-p "finished" event)
(message "✓ Finished playing: %s" url-display))
((string-match-p "exited abnormally" event)
- (message "✗ Playback failed: %s" url-display)
- (with-current-buffer (process-buffer proc)
- (goto-char (point-min))
- (when (re-search-forward "ERROR:" nil t)
- (cj/log-silently "DEBUG: yt-dlp error: %s"
- (buffer-substring-no-properties
- (line-beginning-position)
- (line-end-position)))))))
+ (message "✗ Playback failed: %s" url-display)))
(when (string-match-p "finished\\|exited" event)
(kill-buffer (process-buffer proc)))))))))
diff --git a/tests/test-external-open--open-with-argv.el b/tests/test-external-open--open-with-argv.el
new file mode 100644
index 00000000..27a7e811
--- /dev/null
+++ b/tests/test-external-open--open-with-argv.el
@@ -0,0 +1,59 @@
+;;; test-external-open--open-with-argv.el --- Tests for cj/--open-with-argv -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/--open-with-argv, the pure builder that turns the
+;; user-typed "open with" command plus a file path into an argv list.
+;; The argv shape is the hardening: the file is one list element, so paths
+;; with spaces or shell metacharacters never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: bare program, program with args, quoted argument
+;; - Boundary Cases: file with spaces and metacharacters stays one element
+;; - Error Cases: empty and whitespace-only commands
+;;
+;;; Code:
+
+(require 'ert)
+(require 'external-open)
+
+;;; Normal Cases
+
+(ert-deftest test-external-open--open-with-argv-normal-bare-program ()
+ "Normal: a bare program name yields (PROGRAM FILE)."
+ (should (equal (cj/--open-with-argv "vlc" "/tmp/foo.mp4")
+ '("vlc" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-program-with-args ()
+ "Normal: a command typed with arguments splits into argv words."
+ (should (equal (cj/--open-with-argv "mpv --fs --loop" "/tmp/foo.mp4")
+ '("mpv" "--fs" "--loop" "/tmp/foo.mp4"))))
+
+(ert-deftest test-external-open--open-with-argv-normal-quoted-arg-survives ()
+ "Normal: a double-quoted argument stays one word."
+ (should (equal (cj/--open-with-argv "player \"two words\"" "/tmp/foo.mp4")
+ '("player" "two words" "/tmp/foo.mp4"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-spaces ()
+ "Boundary: a path with spaces is one argv element, untouched."
+ (let ((file "/tmp/my file (draft).mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+(ert-deftest test-external-open--open-with-argv-boundary-file-with-metacharacters ()
+ "Boundary: shell metacharacters in the path arrive verbatim."
+ (let ((file "/tmp/a;b&c$(d)'e.mp4"))
+ (should (equal (car (last (cj/--open-with-argv "vlc" file))) file))))
+
+;;; Error Cases
+
+(ert-deftest test-external-open--open-with-argv-error-empty-command ()
+ "Error: an empty command signals user-error."
+ (should-error (cj/--open-with-argv "" "/tmp/foo.mp4") :type 'user-error))
+
+(ert-deftest test-external-open--open-with-argv-error-whitespace-command ()
+ "Error: a whitespace-only command signals user-error."
+ (should-error (cj/--open-with-argv " " "/tmp/foo.mp4") :type 'user-error))
+
+(provide 'test-external-open--open-with-argv)
+;;; test-external-open--open-with-argv.el ends here
diff --git a/tests/test-external-open-commands.el b/tests/test-external-open-commands.el
index 3d8adc15..3b2d32b8 100644
--- a/tests/test-external-open-commands.el
+++ b/tests/test-external-open-commands.el
@@ -64,19 +64,28 @@
(should-error (cj/open-this-file-with "vlc") :type 'user-error)))
(ert-deftest test-external-open-open-this-file-with-spawns-detached-process ()
- "Normal: posix path invokes `call-process-shell-command' with nohup + bg."
- (let ((cmd nil))
+ "Normal: posix path launches an argv `call-process' with DESTINATION 0."
+ (let ((captured nil))
(with-temp-buffer
- (setq buffer-file-name "/tmp/foo.mp4")
+ (setq buffer-file-name "/tmp/my file.mp4")
(cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
- ((symbol-function 'call-process-shell-command)
- (lambda (c _infile _buf &rest _)
- (setq cmd c))))
- (cj/open-this-file-with "vlc"))
+ ((symbol-function 'executable-find)
+ (lambda (&rest _) "/usr/bin/vlc"))
+ ((symbol-function 'call-process)
+ (lambda (&rest args) (setq captured args) 0)))
+ (cj/open-this-file-with "vlc --fs"))
(setq buffer-file-name nil))
- (should (string-match-p "^nohup vlc " cmd))
- (should (string-match-p "&$" cmd))
- (should (string-match-p ">/dev/null" cmd))))
+ (should (equal captured '("vlc" nil 0 nil "--fs" "/tmp/my file.mp4")))))
+
+(ert-deftest test-external-open-open-this-file-with-errors-missing-program ()
+ "Error: a program not on PATH signals user-error before launching."
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/foo.mp4")
+ (cl-letf (((symbol-function 'env-windows-p) (lambda () nil))
+ ((symbol-function 'executable-find) (lambda (&rest _) nil)))
+ (should-error (cj/open-this-file-with "no-such-program")
+ :type 'user-error))
+ (setq buffer-file-name nil)))
;;; cj/find-file-auto
diff --git a/tests/test-media-utils--argv.el b/tests/test-media-utils--argv.el
new file mode 100644
index 00000000..81317b60
--- /dev/null
+++ b/tests/test-media-utils--argv.el
@@ -0,0 +1,72 @@
+;;; test-media-utils--argv.el --- Tests for media-utils argv builders -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the pure helpers behind cj/media-play-it's shell-free
+;; launch: cj/media--yt-dlp-argv (stream-URL resolution command),
+;; cj/media--stream-urls (yt-dlp -g output parsing), and
+;; cj/media--play-argv (player launch argv). Argv lists are the
+;; hardening: URLs and args never meet a shell.
+;;
+;; Test organization:
+;; - Normal Cases: formats present/absent, args split, multi-line output
+;; - Boundary Cases: metacharacter URLs verbatim, empty output, nil args
+;; - Error Cases: none (builders are total; error paths live in the caller)
+;;
+;;; Code:
+
+(require 'ert)
+(require 'media-utils)
+
+;;; cj/media--yt-dlp-argv
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-with-formats ()
+ "Normal: formats join with / behind -f, URL last."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" '("22" "18" "best"))
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-normal-without-formats ()
+ "Normal: nil formats drops the -f pair."
+ (should (equal (cj/media--yt-dlp-argv "https://example.com/v" nil)
+ '("yt-dlp" "-g" "https://example.com/v"))))
+
+(ert-deftest test-media-utils--yt-dlp-argv-boundary-metacharacter-url ()
+ "Boundary: a URL with shell metacharacters stays one verbatim element."
+ (let ((url "https://example.com/v?a=1&b=$(x);c='d'"))
+ (should (equal (car (last (cj/media--yt-dlp-argv url nil))) url))))
+
+;;; cj/media--stream-urls
+
+(ert-deftest test-media-utils--stream-urls-normal-two-lines ()
+ "Normal: each non-empty output line is one stream URL."
+ (should (equal (cj/media--stream-urls "https://a/video\nhttps://a/audio\n")
+ '("https://a/video" "https://a/audio"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-blank-and-crlf ()
+ "Boundary: blank lines and CR line endings are stripped."
+ (should (equal (cj/media--stream-urls "https://a/v\r\n\n \nhttps://a/u\r\n")
+ '("https://a/v" "https://a/u"))))
+
+(ert-deftest test-media-utils--stream-urls-boundary-empty-output ()
+ "Boundary: empty output yields nil."
+ (should-not (cj/media--stream-urls "")))
+
+;;; cj/media--play-argv
+
+(ert-deftest test-media-utils--play-argv-normal-args-split ()
+ "Normal: the player's raw args string splits into argv words."
+ (should (equal (cj/media--play-argv "vlc" "--no-video --intf dummy"
+ '("https://a/v"))
+ '("vlc" "--no-video" "--intf" "dummy" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-nil-args ()
+ "Boundary: nil args yields program + URLs only."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v"))
+ '("mpv" "https://a/v"))))
+
+(ert-deftest test-media-utils--play-argv-boundary-multiple-urls ()
+ "Boundary: every resolved stream URL is appended in order."
+ (should (equal (cj/media--play-argv "mpv" nil '("https://a/v" "https://a/u"))
+ '("mpv" "https://a/v" "https://a/u"))))
+
+(provide 'test-media-utils--argv)
+;;; test-media-utils--argv.el ends here
diff --git a/tests/test-media-utils.el b/tests/test-media-utils.el
index 841b6faf..c682dffe 100644
--- a/tests/test-media-utils.el
+++ b/tests/test-media-utils.el
@@ -38,34 +38,41 @@
;; ----------------------------- cj/media-play-it ------------------------------
(ert-deftest test-media-play-it-direct-playback-command ()
- "Normal: a player that needs no stream URL gets a plain command, no yt-dlp."
+ "Normal: a player that needs no stream URL launches an argv process, no yt-dlp."
(let (captured cj/default-media-player)
(setq cj/default-media-player 'mpv)
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/mpv"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "mpv" captured))
- (should (string-match-p "example\\.com" captured))
- (should-not (string-match-p "yt-dlp" captured))))
-
-(ert-deftest test-media-play-it-stream-url-wraps-yt-dlp ()
- "Normal: a player needing a stream URL wraps the URL in a yt-dlp -g call."
- (let (captured cj/default-media-player)
+ ;; (NAME BUFFER PROGRAM . ARGS) -- program + args are the argv.
+ (should (equal (nthcdr 2 captured) '("mpv" "https://example.com/v")))
+ (should-not (member "yt-dlp" captured))))
+
+(ert-deftest test-media-play-it-stream-url-resolves-via-yt-dlp ()
+ "Normal: a stream-URL player resolves through a yt-dlp -g capture, then
+launches the player with the resolved URL as argv -- no shell either step."
+ (let (yt-argv captured cj/default-media-player)
(setq cj/default-media-player 'vlc)
- (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/vlc"))
- ((symbol-function 'start-process-shell-command)
- (lambda (_n _b cmd) (setq captured cmd) 'proc))
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (program _infile _dest _display &rest args)
+ (setq yt-argv (cons program args))
+ (insert "https://stream.example.com/resolved\n")
+ 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest args) (setq captured args) 'proc))
((symbol-function 'set-process-sentinel) #'ignore)
((symbol-function 'message) #'ignore)
((symbol-function 'cj/log-silently) #'ignore))
(cj/media-play-it "https://example.com/v"))
- (should (string-match-p "yt-dlp" captured))
- (should (string-match-p "-g" captured))
- (should (string-match-p "-f 22/18/best" captured))))
+ (should (equal yt-argv
+ '("yt-dlp" "-f" "22/18/best" "-g" "https://example.com/v")))
+ (should (equal (nthcdr 2 captured)
+ '("vlc" "https://stream.example.com/resolved")))))
(ert-deftest test-media-play-it-missing-player-errors ()
"Error: an unavailable player command signals an error before launching."
@@ -74,6 +81,39 @@
(cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) nil)))
(should-error (cj/media-play-it "https://example.com/v")))))
+(ert-deftest test-media-play-it-missing-yt-dlp-errors ()
+ "Error: a stream-URL player with no yt-dlp on PATH aborts before resolving."
+ (let (cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (cmd &rest _) (and (equal cmd "vlc") "/usr/bin/vlc"))))
+ (should-error (cj/media-play-it "https://example.com/v")))))
+
+(ert-deftest test-media-play-it-yt-dlp-failure-errors ()
+ "Error: a non-zero yt-dlp exit surfaces as an error, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process)
+ (lambda (&rest _) (insert "ERROR: no video\n") 1))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
+(ert-deftest test-media-play-it-yt-dlp-empty-output-errors ()
+ "Error: a zero-exit yt-dlp with no output still errors, player never launches."
+ (let (launched cj/default-media-player)
+ (setq cj/default-media-player 'vlc)
+ (cl-letf (((symbol-function 'executable-find) (lambda (_ &rest _) "/usr/bin/x"))
+ ((symbol-function 'call-process) (lambda (&rest _) 0))
+ ((symbol-function 'start-process)
+ (lambda (&rest _) (setq launched t) 'proc))
+ ((symbol-function 'message) #'ignore))
+ (should-error (cj/media-play-it "https://example.com/v")))
+ (should-not launched)))
+
;; ------------------------------- cj/yt-dl-it ---------------------------------
(ert-deftest test-media-yt-dl-it-errors-without-yt-dlp ()