Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions spot-embark.el
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,32 @@
(switch-to-buffer buf)
(goto-char (point-min))))

(defun spot--quote-field-value (value)
"Wrap VALUE in double quotes for use in a Spotify field filter.
Embedded double quotes are stripped, since Spotify search syntax
defines no escape sequence."
(concat "\"" (replace-regexp-in-string "\"" "" value) "\""))

(defun spot-action--list-album-tracks (item)
"Search for tracks on the album represented by ITEM."
(let* ((table (get-text-property 0 'multi-data item))
(album-name (ht-get* table 'name))
(artist-name (ht-get* (nth 0 (ht-get* table 'artists)) 'name)))
(spot-consult-search
(concat
"album:" album-name
"album:" (spot--quote-field-value album-name)
" "
"artist:" artist-name " -- --type=track"))))
"artist:" (spot--quote-field-value artist-name)
" -- --type=track"))))

(defun spot-action--list-artist-tracks (item)
"Search for tracks by the artist represented by ITEM."
(let* ((table (get-text-property 0 'multi-data item))
(artist-name (ht-get* table 'name)))
(spot-consult-search
(concat "artist:" artist-name " -- --type=track"))))
(concat
"artist:" (spot--quote-field-value artist-name)
" -- --type=track"))))

(defun spot-action--list-playlist-tracks (item)
"List tracks in the playlist represented by ITEM."
Expand Down
73 changes: 50 additions & 23 deletions spot-search.el
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

(require 'ht)
(require 'dash)
(require 'url-util)

(require 'spot-util)
(require 'spot-var)
Expand Down Expand Up @@ -61,23 +62,42 @@
(defvar spot--candidates-audiobook '()
"Current audiobook candidates from the most recent search.")

(defun spot--find-args-separator (input)
"Return the start index of the args separator in INPUT, or nil.
The separator is whitespace, \"--\", whitespace. Occurrences
inside double-quoted regions of INPUT are skipped."
(let ((i 0) (len (length input)) (in-quote nil) (pos nil))
(while (and (null pos) (< i len))
(let ((c (aref input i)))
(cond
((eq c ?\")
(setq in-quote (not in-quote))
(setq i (1+ i)))
((and (not in-quote)
(eq (string-match "\\s-+--\\s-+" input i) i))
(setq pos i))
(t (setq i (1+ i))))))
pos))

(defun spot--parse-command (input)
"Parse INPUT into a query and optional arguments.
Returns a list of (QUERY ARGS) where ARGS is an alist.
Arguments are separated from the query by \" -- \" and are
in the form \"--key=value\"."
(if (string-match "\\(.*?\\)\\s-+--\\s-+\\(.*\\)" input)
(let* ((query (match-string 1 input))
(args-str (match-string 2 input))
(args (mapcar
(lambda (arg)
(when (string-match
"\\(?:--\\)?\\([^=]+\\)=\\(.*\\)" arg)
(cons (match-string 1 arg)
(match-string 2 arg))))
(split-string args-str))))
(list query (delq nil args)))
(list input nil)))
in the form \"--key=value\". Occurrences of \" -- \" inside
double-quoted regions of INPUT are treated as part of the query."
(let ((sep (spot--find-args-separator input)))
(if (and sep (string-match "\\s-+--\\s-+" input sep))
(let* ((query (substring input 0 sep))
(args-str (substring input (match-end 0)))
(args (mapcar
(lambda (arg)
(when (string-match
"\\(?:--\\)?\\([^=]+\\)=\\(.*\\)" arg)
(cons (match-string 1 arg)
(match-string 2 arg))))
(split-string args-str))))
(list query (delq nil args)))
(list input nil))))

(defun spot--transform-alist-to-q-params (alist)
"Transform ALIST into URL query parameter string."
Expand All @@ -88,19 +108,26 @@ in the form \"--key=value\"."
pairs "")
"")))

(defun spot--build-search-q-params (parsed-command)
"Build the search URL query parameter string for PARSED-COMMAND.
PARSED-COMMAND is the result of `spot--parse-command'. A default
`type' covering every supported item kind is included unless the
user supplied their own `type' argument."
(let* ((query (car parsed-command))
(args-alist (cadr parsed-command))
(args (spot--transform-alist-to-q-params (cdr parsed-command)))
(default-types
(unless (assoc "type" args-alist)
"&type=album,artist,playlist,track,show,episode,audiobook")))
Comment on lines 102 to +121

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spot--transform-alist-to-q-params is documented as taking an alist, but it actually expects a list whose car is the alist (it does (car alist)). The new spot--build-search-q-params reinforces this by passing (cdr parsed-command) even though args-alist is already available, which makes the API easy to misuse and hard to maintain. Consider updating spot--transform-alist-to-q-params to accept the plain args alist (or fix the docstring), and then call it with args-alist here to avoid the extra wrapper structure.

Copilot uses AI. Check for mistakes.
(concat "?q=" (url-hexify-string query)
(or default-types "")
args)))

(defun spot--search-items (input)
"Search for items on Spotify based on INPUT.
Returns a hash table of search results."
(let* ((parsed-command (spot--parse-command input))
(query (car parsed-command))
(args (cdr parsed-command))
(args (spot--transform-alist-to-q-params args))
(q-params (concat
"?type=" "album," "artist,"
"playlist," "track," "show," "episode,"
"audiobook"
"&q=" query
args))
(let* ((q-params (spot--build-search-q-params
(spot--parse-command input)))
(alist (spot-request
:method "GET"
:url spot-search-url
Expand Down
45 changes: 45 additions & 0 deletions test/spot-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,51 @@
(should (equal (car result) "jazz"))
(should (= (length (cadr result)) 2))))

(ert-deftest spot-test-parse-command/quoted-field-with-separator ()
"A \" -- \" inside a double-quoted field is part of the query."
(let ((result (spot--parse-command
"album:\"Foo -- Live\" artist:\"Bar\" -- --type=track")))
(should (equal (car result) "album:\"Foo -- Live\" artist:\"Bar\""))
(should (equal (cadr result) '(("type" . "track"))))))

(ert-deftest spot-test-parse-command/quoted-field-no-args ()
"A quoted field with an internal \" -- \" but no real args separator."
(let ((result (spot--parse-command "album:\"Foo -- Live\"")))
(should (equal (car result) "album:\"Foo -- Live\""))
(should-not (cadr result))))

(ert-deftest spot-test-parse-command/quoted-field-with-spaces ()
"Spaces inside a quoted field are preserved in the query."
(let ((result (spot--parse-command
"artist:\"Pink Floyd\" -- --type=track")))
(should (equal (car result) "artist:\"Pink Floyd\""))
(should (equal (cadr result) '(("type" . "track"))))))


;;; spot--build-search-q-params

(ert-deftest spot-test-build-search-q-params/default-types-when-no-args ()
"The default type list is included when no args are given."
(let ((q (spot--build-search-q-params '("radiohead" nil))))
(should (string-match-p "q=radiohead" q))
(should (string-match-p
"&type=album,artist,playlist,track,show,episode,audiobook"
q))))

(ert-deftest spot-test-build-search-q-params/user-type-replaces-default ()
"A user-supplied type arg replaces the default type list."
(let ((q (spot--build-search-q-params
'("foo" (("type" . "track"))))))
(should-not (string-match-p "type=album," q))
(should (string-match-p "&type=track" q))))

(ert-deftest spot-test-build-search-q-params/non-type-args-keep-default ()
"Non-type args don't suppress the default type list."
(let ((q (spot--build-search-q-params
'("foo" (("market" . "US"))))))
(should (string-match-p "&type=album," q))
(should (string-match-p "&market=US" q))))


Comment on lines +141 to 142

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new spot--build-search-q-params URL-encodes q via url-hexify-string, but the added tests only cover default-type behavior and not the encoding itself. Add a test that uses a query containing spaces/quotes/field syntax (e.g. artist:"Pink Floyd" or album:"Foo -- Live") and asserts the resulting q-params contains the expected percent-encoding (at least %20/%22/%3A), so regressions in escaping are caught.

Suggested change
(ert-deftest spot-test-build-search-q-params/url-encodes-query ()
"The q param is percent-encoded for spaces, quotes, and field syntax."
(let ((q (spot--build-search-q-params
'("artist:\"Pink Floyd\"" nil))))
(should (string-match-p "q=artist%3A%22Pink%20Floyd%22" q))
(should (string-match-p "%3A" q))
(should (string-match-p "%22" q))
(should (string-match-p "%20" q))))

Copilot uses AI. Check for mistakes.
;;; spot--filter and spot--type-equals

Expand Down
Loading