fordon_greeman / voidfiles

Branch: 2079ec5
Switch branches/tags
main
theidioticdev add emacs 2079ec5 23h ago
431 lines · 17.1 KB Raw Blame History
1 ;;; emms-radio-browser.el --- EMMS client for radio-brower API -*- lexical-binding: t; -*-
2
3 ;; Copyright (C) 2025 Free Software Foundation, Inc.
4
5 ;; Author: Fran Burstall <fran.burstall@gmail.com>
6 ;; Keywords: emms, multimedia
7
8 ;; EMMS is free software; you can redistribute it and/or modify it
9 ;; under the terms of the GNU General Public License as published by
10 ;; the Free Software Foundation; either version 3, or (at your option)
11 ;; any later version.
12 ;;
13 ;; EMMS is distributed in the hope that it will be useful, but WITHOUT
14 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
15 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
16 ;; License for more details.
17 ;;
18 ;; You should have received a copy of the GNU General Public License
19 ;; along with EMMS; see the file COPYING. If not, write to the Free
20 ;; Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
21 ;; MA 02110-1301, USA.
22
23 ;;; Commentary:
24
25 ;; This package enables searches for internet radio streams against
26 ;; the radio-browser API (https://www.radio-browser.info).
27 ;; Successful searches return an EMMS playlist of hits.
28
29 ;; Entry points:
30 ;; emms-radio-browser-search-by-name
31 ;; emms-radio-browser-search-by-url
32 ;; emms-radio-browser-full-search
33
34 ;; `emms-radio-browser-full-search' needs the `transient' package
35 ;; (built in to Emacs since v28.1).
36
37 ;;; Code:
38
39 ;;* Requires
40 (require 'dns)
41 (require 'url)
42 (require 'json)
43 (require 'emms-playlist-mode)
44 (require 'seq)
45 (require 'transient)
46
47 ;;* Constants
48
49 (defconst emms-radio-browser-server-server
50 "all.api.radio-browser.info"
51 "Server to query for list of radio-browser servers.")
52
53 (defconst emms-radio-browser-search-endpoint
54 "/json/stations/search"
55 "Endpoint for station searches against the radio-browser API.")
56
57 (defconst emms-radio-browser-url-endpoint
58 "/json/stations/byurl"
59 "Endpoint for station URL searches against the radio-browser API.")
60
61 (defvar emms-radio-browser-user-agent
62 "EMMS radio-browser"
63 "The user-agent we declare to the server.")
64
65 (defvar emms-radio-browser-search-limit 30
66 "Maximum number of hits to pull from the server.")
67
68 (defvar emms-radio-browser-search-order "votes"
69 "Default field to order results by.")
70
71 (defvar emms-radio-browser-search-descending t
72 "Non-nil if results should be sorted in descending order.")
73
74 (defconst emms-radio-browser-order-fields
75 '("name"
76 "url"
77 "homepage"
78 "favicon"
79 "tags"
80 "country"
81 "state"
82 "language"
83 "votes"
84 "codec"
85 "bitrate"
86 "lastcheckok"
87 "lastchecktime"
88 "clicktimestamp"
89 "clickcount"
90 "clicktrend"
91 "changetimestamp"
92 "random")
93 "Search fields we can order the results by.")
94
95
96 ;;* Query the server
97
98 ;;** Target url
99 ;; The API asks us to get a list of servers from a DNS lookup on
100 ;; all.api.radio-browser.info, do reverse DNS on the IP
101 ;; addresses so found and then choose one at random. In fact, there
102 ;; are only three servers but we want play nice and so do as we are
103 ;; asked.
104 (defun emms-radio-browser-get-server-list ()
105 "Get the list of radio-browser servers.
106
107 Error out if the list is empty as this suggests we have network problems
108 and so are doomed."
109 (let ((server-list
110 (mapcar (lambda (ip) (dns-query ip nil nil 'reverse))
111 (mapcar (lambda (it) (car (alist-get 'data it)))
112 (car (alist-get 'answers
113 (dns-query emms-radio-browser-server-server nil 'full)))))))
114 (if server-list server-list (error "Network problem: DNS lookup failed"))))
115
116 (defun emms-radio-browser-base-url ()
117 "Return a (randomised) radio-browser URL."
118 (concat "http://" (seq-random-elt (emms-radio-browser-get-server-list))))
119
120 ;;** Payload
121 (defun emms-radio-browser-query-template ()
122 "Return basic search template.
123
124 This is an alist suitable for `json-encode'."
125 (list (cons 'limit emms-radio-browser-search-limit)
126 (cons 'order emms-radio-browser-search-order)
127 (cons 'reverse emms-radio-browser-search-descending)
128 (cons 'hidebroken t)))
129
130 (defun emms-radio-browser-search-by-name-payload (name)
131 "Return payload to search by name NAME."
132 (let ((payload (emms-radio-browser-query-template)))
133 (push (cons 'name name) payload)
134 payload))
135
136 ;;** Full search
137 ;; We use a transient for this which will need a little scene-setting.
138 ;; Accessible applications of the transient library are a little thin
139 ;; on the ground so let us explain what we are doing in a bit more
140 ;; detail than usual.
141 ;;
142 ;; The entry point is `emms-radio-browser-full-search' which is a kind
143 ;; of dispatcher (in transient terminimology it is a "prefix"). It is
144 ;; populated with data fields, called "infixes", with which the user
145 ;; interacts and commands, called "suffixes", which can read the data
146 ;; collected in the infixes and do something with it.
147 ;;
148 ;; All of these things are EIEIO classes.
149 ;;
150 ;; Our implementation was heavily inspired by the project:
151 ;; https://codeberg.org/martianh/tp.el
152
153 ;; The idea is to equip each infix with an alist-key slot which stores
154 ;; a symbol. We arrange that each infix reports its value as a cons
155 ;; cell whose car is this symbol and whose cdr the contents of the
156 ;; value slot. The prefix reports the list of all these cons cells to
157 ;; a suffix so what the suffix receives is an alist---in this way we
158 ;; construct a query of exactly the kind we need to feed to the
159 ;; radio-browser server!
160
161 ;; We subclass a suitable infix class to add the alist-key slot.
162 (defclass emms-radio-browser-field (transient-option)
163 ((format :initarg :format :initform " %k %-13d %v")
164 (alist-key :initarg :alist-key))
165 "An infix class for string fields.")
166
167 ;; We subclass this to get something suitable for boolean fields.
168 ;; Why? Because we display their values differently in the transient
169 ;; UI and also because our alist will be fed to `json-encode' so we
170 ;; treat nil specially.
171 (defclass emms-radio-browser-bool (emms-radio-browser-field)
172 ()
173 "An infix class for boolean fields.")
174
175 ;; `transient-format-value' determines how the infix value is shown in
176 ;; the transient UI
177
178 (cl-defmethod transient-format-value ((obj emms-radio-browser-field))
179 "Format the value of OBJ.
180
181 Nil is formatted as the empty string."
182 (or (oref obj value) ""))
183
184 (cl-defmethod transient-format-value ((obj emms-radio-browser-bool))
185 "Format the value of boolean OBJ.
186
187 Returns either \"True\" or \"False\"."
188 (if (oref obj value) "True" "False"))
189
190 ;; `transient-infix-value' returns the infix value to the calling
191 ;; suffix: as discussed above, we wrap the value into a cons cell.
192 (cl-defmethod transient-infix-value ((obj emms-radio-browser-field))
193 "Return the infix value of OBJ as a cons cell if non-nil."
194 (when-let* ((val (oref obj value)))
195 (cons (oref obj alist-key) val)))
196
197 (cl-defmethod transient-infix-value ((obj emms-radio-browser-bool))
198 "Return the infix value of OBJ as a cons cell."
199 (let ((val (oref obj value)))
200 (cons (oref obj alist-key) (if val val :json-false))))
201
202 ;; `transient-init-value' is called to initialise each infix when the
203 ;; prefix starts up. We set some default values by reading them from
204 ;; `emms-radio-browser-query-template'.
205 (cl-defmethod transient-init-value ((obj emms-radio-browser-field))
206 "Initialise OBJ, an option."
207 (let ((key (oref obj alist-key)))
208 (oset obj value
209 (alist-get key (emms-radio-browser-query-template)))))
210
211 ;; `transient-infix-read' sets the value of the infix from the user.
212 ;; Usually, the method of the parent class `transient-option' is
213 ;; perfect for this but, for booleans, it suffices to toggle the
214 ;; existing value.
215 (cl-defmethod transient-infix-read ((obj emms-radio-browser-bool))
216 "Toggle the (boolean) value of OBJ."
217 (not (oref obj value)))
218
219 ;; Now for the suffices that acts on the data we have gathered.
220
221 ;; This is the main suffix that slurps the query alist and passes it to the server.
222 (transient-define-suffix emms-radio-browser-execute-full-search (args)
223 "Extract query from `emms-radio-browser-full-search' and execute it.
224
225 Switches to an EMMS playlist containing the results."
226 :transient 'transient--do-return
227 (interactive (list (transient-args transient-current-command)))
228 (emms-radio-browser-query-api args emms-radio-browser-search-endpoint))
229
230 ;; Here is another which just shows the query in the message buffer
231 ;; for debugging purposes
232 (transient-define-suffix emms-radio-browser-show-full-search (args)
233 "Extract query from `emms-radio-browser-full-search' and show it."
234 :transient 'transient--do-return
235 (interactive (list (transient-args transient-current-command)))
236 (message "%S" args))
237
238 ;; Finally, we define the prefix. Sadly emacs-29, ships with a
239 ;; prehistoric version of transient which misses both a level-toggling
240 ;; command and the transient-information class. So we use a macro to
241 ;; give different defintions of the prefix accordinding to emacs version.
242
243 (defmacro emms-radio-browser--make-full-search ()
244 "Define a transient with features conditional on Emacs version."
245 (if (and (< emacs-major-version 30) (not (boundp 'transient-version)))
246 '(transient-define-prefix emms-radio-browser-full-search-prefix ()
247 "Construct a search query by filling in a form.
248
249 Optionally dispatch it to the radio-browser server and switch to an
250 EMMS playlist of results."
251 ["EMMS radio browser full search: hit coloured letters to set/unset fields\n"
252 ["Search terms:"
253 ("n" "Name" "Station name" :alist-key name :class emms-radio-browser-field)
254 ("t" "Tags" "Tags (comma separated)" :alist-key tagList :class emms-radio-browser-field)
255 ("c" "Country" "Country" :alist-key country :class emms-radio-browser-field)
256 ("l" "Language" "Language" :alist-key language :class emms-radio-browser-field)]
257 ["Exact matches for:"
258 ("xn" "Name" "Exact names" :alist-key nameExact :class emms-radio-browser-bool)
259 ("xt" "Tags" "Exact tags" :alist-key tagExact :class emms-radio-browser-bool)
260 ("xc" "Country" "Exact country" :alist-key countryExact :class emms-radio-browser-bool)
261 ("xl" "Language" "Exact language" :alist-key languageExact :class emms-radio-browser-bool)]
262 ["Advanced search terms:" :pad-keys t
263 ("C" "Codec" "Codec" :alist-key codec :class emms-radio-browser-field)
264 ("bn" "Minimum bitrate" "Minimum bitrate (kb/s)" :alist-key bitrateMin :class emms-radio-browser-field
265 :reader transient-read-number-N0)
266 ("bz" "Maximum bitrate" "Maximum bitrate (kb/s)" :alist-key bitrateMin :class emms-radio-browser-field
267 :reader transient-read-number-N0)
268 ("k" "Country code" "Country code" :alist-key countrycode :class emms-radio-browser-field)]]
269 ["Search parameters:"
270 ("m" "Maximum hits" "Maximum Hits" :alist-key limit :class emms-radio-browser-field
271 :reader transient-read-number-N+ :always-read t)
272 ("o" "Order by" "Order by" :alist-key order :class emms-radio-browser-field
273 :choices (lambda () emms-radio-browser-order-fields) :always-read t)
274 ("d" "Descending" "Descending order" :alist-key reverse :class emms-radio-browser-bool)]
275 [:class transient-row "Actions:"
276 ("C-c C-c" "Execute search" emms-radio-browser-execute-full-search)
277 ("C-c C-k" "Abandon search" ignore)
278 ])
279 '(transient-define-prefix emms-radio-browser-full-search-prefix ()
280 "Construct a search query by filling in a form.
281
282 Optionally dispatch it to the radio-browser server and switch to an
283 EMMS playlist of results."
284 :column-widths '(30 20 30)
285 [:description "EMMS radio browser full search"
286 (:info "Hit coloured letters to set/unset fields")
287 (:info '(lambda () (concat (propertize "C-x a" 'face 'help-key-binding)
288 " to toggle advanced search")))
289 (:info '(lambda () (concat (propertize "C-c C-c" 'face 'help-key-binding)
290 " to execute the search")))
291 (:info '(lambda () (concat (propertize "C-c C-k" 'face 'help-key-binding)
292 " to abandon the search")))]
293 [["Search terms:"
294 ("n" "Name" "Station name" :alist-key name :class emms-radio-browser-field)
295 ("t" "Tags" "Tags (comma separated)" :alist-key tagList :class emms-radio-browser-field)
296 ("c" "Country" "Country" :alist-key country :class emms-radio-browser-field)
297 ("l" "Language" "Language" :alist-key language :class emms-radio-browser-field)]
298 [5 "Exact matches for:"
299 ("xn" "Name" "Exact names" :alist-key nameExact :class emms-radio-browser-bool)
300 ("xt" "Tags" "Exact tags" :alist-key tagExact :class emms-radio-browser-bool)
301 ("xc" "Country" "Exact country" :alist-key countryExact :class emms-radio-browser-bool)
302 ("xl" "Language" "Exact language" :alist-key languageExact :class emms-radio-browser-bool)]
303 [5 "Advanced search terms:" :pad-keys t
304 ("C" "Codec" "Codec" :alist-key codec :class emms-radio-browser-field)
305 ("bn" "Minimum bitrate" "Minimum bitrate (kb/s)" :alist-key bitrateMin :class emms-radio-browser-field
306 :reader transient-read-number-N0)
307 ("bz" "Maximum bitrate" "Maximum bitrate (kb/s)" :alist-key bitrateMin :class emms-radio-browser-field
308 :reader transient-read-number-N0)
309 ("k" "Country code" "Country code" :alist-key countrycode :class emms-radio-browser-field)]]
310 ["Search parameters:"
311 ("m" "Maximum hits" "Maximum Hits" :alist-key limit :class emms-radio-browser-field
312 :reader transient-read-number-N+ :always-read t)
313 ("o" "Order by" "Order by" :alist-key order :class emms-radio-browser-field
314 :choices (lambda () emms-radio-browser-order-fields) :always-read t)
315 ("d" "Descending" "Descending order" :alist-key reverse :class emms-radio-browser-bool)]
316 [:class transient-row "Actions:"
317 ("C-c C-c" "Execute search" emms-radio-browser-execute-full-search)
318 ("C-c C-k" "Abandon search" ignore)
319 (6 "s" "Show search" emms-radio-browser-show-full-search)])))
320
321 (emms-radio-browser--make-full-search)
322
323 ;;** Query the server
324
325 (defun emms-radio-browser-query-api (query endpoint)
326 "Send QUERY to radio-browser ENDPOINT.
327
328 QUERY is an alist suitable for `json-encode'."
329 (let* ((target-url (concat (emms-radio-browser-base-url) endpoint))
330 ;; we encode EVERYTHING to stop url-retrieve throwing a wobbly
331 ;; if it encounters non-ascii data, sigh.
332 (user-agent-encoded (encode-coding-string emms-radio-browser-user-agent 'utf-8))
333 (url-request-method "POST")
334 (url-request-data (encode-coding-string (json-encode query) 'utf-8))
335 (url-request-extra-headers `(("Content-type" . "application/json; charset=utf-8")
336 ("User-Agent" . ,user-agent-encoded))))
337 (ignore url-request-method
338 url-request-data
339 url-request-extra-headers)
340 (url-retrieve
341 target-url
342 #'emms-radio-browser-query-callback
343 (list query))))
344
345
346
347 ;;* Handle the reply
348 (defun emms-radio-browser-check-response ()
349 "Error out if server response headers look bad."
350 (let ((ok200 "HTTP/1.1 200 OK"))
351 (if (< (point-max) 1)
352 (error "No response from server"))
353 (if (not (string= ok200 (buffer-substring-no-properties (point-min) 16)))
354 (error "Server not responding correctly"))))
355
356 (defun emms-radio-browser-json-to-track (data)
357 "Convert DATA to EMMS stream-list.
358
359 Tries not to cache the result."
360 (let ((emms-cache-modified-function nil)
361 (emms-cache-set-function nil))
362 (let-alist data
363 (let ((track (emms-track 'streamlist .url))
364 (metadata (list .name .url 1 'streamlist)))
365 (emms-track-set track 'metadata metadata)
366 track))))
367
368 (defun emms-radio-browser-display-tracks (tracks)
369 "Load TRACKS into new playlist buffer and display same."
370 (let ((buf (emms-playlist-new "*EMMS radio-browser search results*")))
371 (with-current-buffer buf
372 (mapc #'emms-playlist-insert-track tracks)
373 (emms-playlist-select (point-min))
374 (emms-playlist-mode-center-current)
375 ;; (emms-playlist-set-playlist-buffer)
376 (switch-to-buffer buf))))
377
378
379 (defun emms-radio-browser-query-callback (status &optional cbargs)
380 "Process server response and display playlist of results.
381
382 Mandatory callback arguments STATUS and CBARGS are ignored."
383 ;; Check response OK.
384 (ignore status cbargs)
385 (set-buffer-multibyte t)
386 (emms-radio-browser-check-response)
387 ;; Slurp json
388 (goto-char (point-min))
389 (let ((response (ignore-errors
390 (re-search-forward "\n\n")
391 (json-read))))
392 (kill-buffer)
393 (if (seq-empty-p response)
394 (message "emms-radio-browser: No matches found!")
395 (emms-radio-browser-display-tracks
396 (mapcar #'emms-radio-browser-json-to-track response)))))
397
398
399 ;;* Entry points
400 ;;;###autoload
401 (defun emms-radio-browser-search-by-name (name)
402 "Search radio-browser for stations matching NAME.
403
404 Switches to an EMMS playlist containing the results."
405 (interactive "sSearch for station name: ")
406 (emms-radio-browser-query-api (emms-radio-browser-search-by-name-payload name)
407 emms-radio-browser-search-endpoint))
408
409 ;;;###autoload
410 (defun emms-radio-browser-search-by-url (url)
411 "Search radio-browser for stations matching URL.
412
413 Switches to an EMMS playlist containing the results."
414 (interactive "sSearch for URL: ")
415 (emms-radio-browser-query-api (list (cons 'url url))
416 emms-radio-browser-url-endpoint))
417
418 ;; Finally load the transient for making a full search. This was
419 ;;conditionally defined above. We wrap in in a function to get the autoload.
420 ;;;###autoload
421 (defun emms-radio-browser-full-search ()
422 "Construct a search query by filling in a form.
423
424 Optionally dispatch it to the radio-browser server and switch to an
425 EMMS playlist of results."
426 (interactive)
427 (call-interactively #'emms-radio-browser-full-search-prefix t))
428
429
430 (provide 'emms-radio-browser)
431 ;;; emms-radio-browser.el ends here