branch: externals/emms
commit 205066b329200b9a3bf19a4af1b4af7d7ea932dc
Merge: 8713a0ee98 c0d2a4a5fe
Author: Fran Burstall <fran.burst...@gmail.com>
Commit: Fran Burstall <fran.burst...@gmail.com>

    Merge branch 'radio-browser'
---
 doc/emms.texinfo      |  38 +++++
 emms-radio-browser.el | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 469 insertions(+)

diff --git a/doc/emms.texinfo b/doc/emms.texinfo
index 804478dd0b..76e514a850 100644
--- a/doc/emms.texinfo
+++ b/doc/emms.texinfo
@@ -81,6 +81,7 @@ Modules and Extensions
 * Lyrics::               Displaying lyrics synchronously.
 * Volume::               Changing the volume.
 * Streaming Audio::      Interface to streaming audio.
+* Radio Browser::        Search for internet radio stations
 * APE / FLAC Commands::  How to play next or previous track in these files.
 * Bookmarks::            Saving a place in a media file.
 * Managing Playlists::   Managing multiple playlists.
@@ -2970,6 +2971,43 @@ station provides that information) by configuring:
 @end lisp
 
 
+@c -------------------------------------------------------------------
+@node Radio Browser
+@chapter Radio Browser
+
+@cindex streaming audio
+@cindex internet radio
+
+We can find new internet radio stations to stream by
+searching the database at
+@url{https://www.radio-browser.info}.  The
+@file{emms-radio-browser.el} package provides the following commands
+to do this:
+
+@defun emms-radio-browser-search-by-name
+Prompts for a station NAME and returns a playlist of
+matching streams.
+@end defun
+
+@defun emms-radio-browser-search-by-url
+Prompts for a station URL and returns a playlist of
+matching streams.
+@end defun
+
+@defun emms-radio-browser-full-search
+Pops up a form to search by name, tags, country or language.
+Returns a playlist of matching streams.
+@end defun
+
+To activate @file{emms-radio-browser.el}, do
+
+@lisp
+(require 'emms-radio-browser)
+@end lisp
+
+You will the @file{transient.el} package to be installed
+(this is built-in since emacs v28.1).
+
 @c -------------------------------------------------------------------
 @node APE / FLAC Commands
 @chapter APE / FLAC Commands
diff --git a/emms-radio-browser.el b/emms-radio-browser.el
new file mode 100644
index 0000000000..179624f682
--- /dev/null
+++ b/emms-radio-browser.el
@@ -0,0 +1,431 @@
+;;; emms-radio-browser.el --- EMMS client for radio-brower API  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Free Software Foundation, Inc.
+
+;; Author: Fran Burstall <fran.burst...@gmail.com>
+;; Keywords: emms, multimedia
+
+;; EMMS is free software; you can redistribute it and/or modify it
+;; under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+;;
+;; EMMS is distributed in the hope that it will be useful, but WITHOUT
+;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+;; or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
+;; License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with EMMS; see the file COPYING.  If not, write to the Free
+;; Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+;; MA 02110-1301, USA.
+
+;;; Commentary:
+
+;;  This package enables searches for internet radio streams against
+;;  the radio-browser API (https://www.radio-browser.info).
+;;  Successful searches return an EMMS playlist of hits.
+
+;; Entry points:
+;; emms-radio-browser-search-by-name
+;; emms-radio-browser-search-by-url
+;; emms-radio-browser-full-search
+
+;; `emms-radio-browser-full-search' needs the `transient' package
+;; (built in to Emacs since v28.1).
+
+;;; Code:
+
+;;* Requires
+(require 'dns)
+(require 'url)
+(require 'json)
+(require 'emms-playlist-mode)
+(require 'seq)
+(require 'transient)
+
+;;* Constants
+
+(defconst emms-radio-browser-server-server
+  "all.api.radio-browser.info"
+  "Server to query for list of radio-browser servers.")
+
+(defconst emms-radio-browser-search-endpoint
+  "/json/stations/search"
+  "Endpoint for station searches against the radio-browser API.")
+
+(defconst emms-radio-browser-url-endpoint
+  "/json/stations/byurl"
+  "Endpoint for station URL searches against the radio-browser API.")
+
+(defvar emms-radio-browser-user-agent
+  "EMMS radio-browser"
+  "The user-agent we declare to the server.")
+
+(defvar emms-radio-browser-search-limit 30
+  "Maximum number of hits to pull from the server.")
+
+(defvar emms-radio-browser-search-order "votes"
+  "Default field to order results by.")
+
+(defvar emms-radio-browser-search-descending t
+  "Non-nil if results should be sorted in descending order.")
+
+(defconst emms-radio-browser-order-fields
+  '("name"
+    "url"
+    "homepage"
+    "favicon"
+    "tags"
+    "country"
+    "state"
+    "language"
+    "votes"
+    "codec"
+    "bitrate"
+    "lastcheckok"
+    "lastchecktime"
+    "clicktimestamp"
+    "clickcount"
+    "clicktrend"
+    "changetimestamp"
+    "random")
+  "Search fields we can order the results by.")
+
+
+;;* Query the server
+
+;;** Target url
+;; The API asks us to get a list of servers from a DNS lookup on
+;; all.api.radio-browser.info, do reverse DNS on the IP
+;; addresses so found and then choose one at random.  In fact, there
+;; are only three servers but we want play nice and so do as we are
+;; asked.
+(defun emms-radio-browser-get-server-list ()
+  "Get the list of radio-browser servers.
+
+Error out if the list is empty as this suggests we have network problems
+and so are doomed."
+  (let ((server-list
+        (mapcar (lambda (ip) (dns-query ip nil nil 'reverse))
+                (mapcar (lambda (it) (car (alist-get 'data it)))
+                        (car (alist-get 'answers
+                                        (dns-query 
emms-radio-browser-server-server nil 'full)))))))
+    (if server-list server-list (error "Network problem: DNS lookup failed"))))
+
+(defun emms-radio-browser-base-url ()
+  "Return a (randomised) radio-browser URL."
+  (concat "http://"; (seq-random-elt (emms-radio-browser-get-server-list))))
+
+;;** Payload
+(defun emms-radio-browser-query-template ()
+  "Return basic search template.
+
+This is an alist suitable for `json-encode'."
+  (list (cons 'limit emms-radio-browser-search-limit)
+       (cons 'order emms-radio-browser-search-order)
+       (cons 'reverse emms-radio-browser-search-descending)
+       (cons 'hidebroken t)))
+
+(defun emms-radio-browser-search-by-name-payload (name)
+  "Return payload to search by name NAME."
+  (let ((payload (emms-radio-browser-query-template)))
+    (push (cons 'name name) payload)
+    payload))
+
+;;** Full search
+;; We use a transient for this which will need a little scene-setting.
+;; Accessible applications of the transient library are a little thin
+;; on the ground so let us explain what we are doing in a bit more
+;; detail than usual.
+;;
+;; The entry point is `emms-radio-browser-full-search' which is a kind
+;; of dispatcher (in transient terminimology it is a "prefix").  It is
+;; populated with data fields, called "infixes", with which the user
+;; interacts and commands, called "suffixes", which can read the data
+;; collected in the infixes and do something with it.
+;;
+;; All of these things are EIEIO classes.
+;;
+;; Our implementation was heavily inspired by the project:
+;; https://codeberg.org/martianh/tp.el
+
+;; The idea is to equip each infix with an alist-key slot which stores
+;; a symbol.  We arrange that each infix reports its value as a cons
+;; cell whose car is this symbol and whose cdr the contents of the
+;; value slot.  The prefix reports the list of all these cons cells to
+;; a suffix so what the suffix receives is an alist---in this way we
+;; construct a query of exactly the kind we need to feed to the
+;; radio-browser server!
+
+;; We subclass a suitable infix class to add the alist-key slot.
+(defclass emms-radio-browser-field (transient-option)
+  ((format :initarg :format :initform " %k %-13d %v")
+   (alist-key :initarg :alist-key))
+  "An infix class for string fields.")
+
+;; We subclass this to get something suitable for boolean fields.
+;; Why? Because we display their values differently in the transient
+;; UI and also because our alist will be fed to `json-encode' so we
+;; treat nil specially.
+(defclass emms-radio-browser-bool (emms-radio-browser-field)
+  ()
+  "An infix class for boolean fields.")
+
+;; `transient-format-value' determines how the infix value is shown in
+;; the transient UI
+
+(cl-defmethod transient-format-value ((obj emms-radio-browser-field))
+  "Format the value of OBJ.
+
+Nil is formatted as the empty string."
+  (or (oref obj value) ""))
+
+(cl-defmethod transient-format-value ((obj emms-radio-browser-bool))
+  "Format the value of boolean OBJ.
+
+Returns either \"True\" or \"False\"."
+  (if (oref obj value) "True" "False"))
+
+;; `transient-infix-value' returns the infix value to the calling
+;; suffix: as discussed above, we wrap the value into a cons cell.
+(cl-defmethod transient-infix-value ((obj emms-radio-browser-field))
+  "Return the infix value of OBJ as a cons cell if non-nil."
+  (when-let ((val (oref obj value)))
+    (cons (oref obj alist-key) val)))
+
+(cl-defmethod transient-infix-value ((obj emms-radio-browser-bool))
+  "Return the infix value of OBJ as a cons cell."
+  (let ((val (oref obj value)))
+    (cons (oref obj alist-key) (if val val :json-false))))
+
+;; `transient-init-value' is called to initialise each infix when the
+;; prefix starts up.  We set some default values by reading them from
+;; `emms-radio-browser-query-template'.
+(cl-defmethod transient-init-value ((obj emms-radio-browser-field))
+  "Initialise OBJ, an option."
+  (let ((key (oref obj alist-key)))
+    (oset obj value
+          (alist-get key (emms-radio-browser-query-template)))))
+
+;; `transient-infix-read' sets the value of the infix from the user.
+;; Usually, the method of the parent class `transient-option' is
+;; perfect for this but, for booleans, it suffices to toggle the
+;; existing value.
+(cl-defmethod transient-infix-read ((obj emms-radio-browser-bool))
+  "Toggle the (boolean) value of OBJ."
+  (not (oref obj value)))
+
+;; Now for the suffices that acts on the data we have gathered.
+
+;; This is the main suffix that slurps the query alist and passes it to the 
server.
+(transient-define-suffix emms-radio-browser-execute-full-search (args)
+  "Extract query from `emms-radio-browser-full-search' and execute it.
+
+Switches to an EMMS playlist containing the results."
+  :transient 'transient--do-return
+  (interactive (list (transient-args transient-current-command)))
+  (emms-radio-browser-query-api args emms-radio-browser-search-endpoint))
+
+;; Here is another which just shows the query in the message buffer
+;; for debugging purposes
+(transient-define-suffix emms-radio-browser-show-full-search (args)
+  "Extract query from `emms-radio-browser-full-search' and show it."
+  :transient 'transient--do-return
+  (interactive (list (transient-args transient-current-command)))
+  (message "%S" args))
+
+;; Finally, we define the prefix.  Sadly emacs-29, ships with a
+;; prehistoric version of transient which misses both a level-toggling
+;; command and the transient-information class.  So we use a macro to
+;; give different defintions of the prefix accordinding to emacs version.
+
+(defmacro emms-radio-browser--make-full-search ()
+  "Define a transient with features conditional on Emacs version."
+  (if (and (< emacs-major-version 30) (not (boundp 'transient-version)))
+      '(transient-define-prefix emms-radio-browser-full-search-prefix ()
+        "Construct a search query by filling in a form.
+
+Optionally dispatch it to the radio-browser server and switch to an
+EMMS playlist of results."
+        ["EMMS radio browser full search: hit coloured letters to set/unset 
fields\n"
+         ["Search terms:"
+          ("n" "Name" "Station name" :alist-key name :class 
emms-radio-browser-field)
+          ("t" "Tags" "Tags (comma separated)" :alist-key tagList :class 
emms-radio-browser-field)
+          ("c" "Country" "Country" :alist-key country :class 
emms-radio-browser-field)
+          ("l" "Language" "Language" :alist-key language :class 
emms-radio-browser-field)]
+         ["Exact matches for:"
+          ("xn" "Name" "Exact names" :alist-key nameExact :class 
emms-radio-browser-bool)
+          ("xt" "Tags" "Exact tags" :alist-key tagExact :class 
emms-radio-browser-bool)
+          ("xc" "Country" "Exact country" :alist-key countryExact :class 
emms-radio-browser-bool)
+          ("xl" "Language" "Exact language" :alist-key languageExact :class 
emms-radio-browser-bool)]
+         ["Advanced search terms:" :pad-keys t
+          ("C" "Codec" "Codec" :alist-key codec :class 
emms-radio-browser-field)
+          ("bn" "Minimum bitrate" "Minimum bitrate (kb/s)" :alist-key 
bitrateMin :class emms-radio-browser-field
+           :reader transient-read-number-N0)
+          ("bz" "Maximum bitrate" "Maximum bitrate (kb/s)" :alist-key 
bitrateMin :class emms-radio-browser-field
+           :reader transient-read-number-N0)
+          ("k" "Country code" "Country code" :alist-key countrycode :class 
emms-radio-browser-field)]]
+        ["Search parameters:"
+         ("m" "Maximum hits" "Maximum Hits" :alist-key limit :class 
emms-radio-browser-field
+          :reader transient-read-number-N+ :always-read t)
+         ("o" "Order by" "Order by" :alist-key order :class 
emms-radio-browser-field
+          :choices (lambda () emms-radio-browser-order-fields) :always-read t)
+         ("d" "Descending" "Descending order" :alist-key reverse :class 
emms-radio-browser-bool)]
+        [:class transient-row "Actions:"
+                ("C-c C-c" "Execute search" 
emms-radio-browser-execute-full-search)
+                ("C-c C-k" "Abandon search" ignore)
+                ])
+    '(transient-define-prefix emms-radio-browser-full-search-prefix ()
+       "Construct a search query by filling in a form.
+
+Optionally dispatch it to the radio-browser server and switch to an
+EMMS playlist of results."
+       :column-widths '(30 20 30)
+       [:description "EMMS radio browser full search"
+                    (:info "Hit coloured letters to set/unset fields")
+                    (:info '(lambda () (concat (propertize "C-x a" 'face 
'help-key-binding)
+                                               " to toggle advanced search")))
+                    (:info '(lambda () (concat (propertize "C-c C-c" 'face 
'help-key-binding)
+                                               " to execute the search")))
+                    (:info '(lambda () (concat (propertize "C-c C-k" 'face 
'help-key-binding)
+                                               " to abandon the search")))]
+       [["Search terms:"
+        ("n" "Name" "Station name" :alist-key name :class 
emms-radio-browser-field)
+        ("t" "Tags" "Tags (comma separated)" :alist-key tagList :class 
emms-radio-browser-field)
+        ("c" "Country" "Country" :alist-key country :class 
emms-radio-browser-field)
+        ("l" "Language" "Language" :alist-key language :class 
emms-radio-browser-field)]
+       [5 "Exact matches for:"
+          ("xn" "Name" "Exact names" :alist-key nameExact :class 
emms-radio-browser-bool)
+          ("xt" "Tags" "Exact tags" :alist-key tagExact :class 
emms-radio-browser-bool)
+          ("xc" "Country" "Exact country" :alist-key countryExact :class 
emms-radio-browser-bool)
+          ("xl" "Language" "Exact language" :alist-key languageExact :class 
emms-radio-browser-bool)]
+       [5 "Advanced search terms:" :pad-keys t
+          ("C" "Codec" "Codec" :alist-key codec :class 
emms-radio-browser-field)
+          ("bn" "Minimum bitrate" "Minimum bitrate (kb/s)" :alist-key 
bitrateMin :class emms-radio-browser-field
+           :reader transient-read-number-N0)
+          ("bz" "Maximum bitrate" "Maximum bitrate (kb/s)" :alist-key 
bitrateMin :class emms-radio-browser-field
+           :reader transient-read-number-N0)
+          ("k" "Country code" "Country code" :alist-key countrycode :class 
emms-radio-browser-field)]]
+       ["Search parameters:"
+       ("m" "Maximum hits" "Maximum Hits" :alist-key limit :class 
emms-radio-browser-field
+        :reader transient-read-number-N+ :always-read t)
+       ("o" "Order by" "Order by" :alist-key order :class 
emms-radio-browser-field
+        :choices (lambda () emms-radio-browser-order-fields) :always-read t)
+       ("d" "Descending" "Descending order" :alist-key reverse :class 
emms-radio-browser-bool)]
+       [:class transient-row "Actions:"
+              ("C-c C-c" "Execute search" 
emms-radio-browser-execute-full-search)
+              ("C-c C-k" "Abandon search" ignore)
+              (6 "s" "Show search" emms-radio-browser-show-full-search)])))
+
+(emms-radio-browser--make-full-search)
+
+;;** Query the server
+
+(defun emms-radio-browser-query-api (query endpoint)
+  "Send QUERY to radio-browser ENDPOINT.
+
+QUERY is an alist suitable for `json-encode'."
+  (let* ((target-url (concat (emms-radio-browser-base-url) endpoint))
+        ;; we encode EVERYTHING to stop url-retrieve throwing a wobbly
+        ;; if it encounters non-ascii data, sigh.
+        (user-agent-encoded (encode-coding-string 
emms-radio-browser-user-agent 'utf-8))
+        (url-request-method "POST")
+        (url-request-data (encode-coding-string (json-encode query) 'utf-8))
+        (url-request-extra-headers `(("Content-type" . "application/json; 
charset=utf-8")
+                                     ("User-Agent" . ,user-agent-encoded))))
+    (ignore url-request-method
+           url-request-data
+           url-request-extra-headers)
+    (url-retrieve
+     target-url
+     #'emms-radio-browser-query-callback
+     (list query))))
+
+
+
+;;* Handle the reply
+(defun emms-radio-browser-check-response ()
+  "Error out if server response headers look bad."
+  (let ((ok200 "HTTP/1.1 200 OK"))
+    (if (< (point-max) 1)
+       (error "No response from server"))
+    (if (not (string= ok200 (buffer-substring-no-properties (point-min) 16)))
+       (error "Server not responding correctly"))))
+
+(defun emms-radio-browser-json-to-track (data)
+  "Convert DATA to EMMS stream-list.
+
+Tries not to cache the result."
+  (let ((emms-cache-modified-function nil)
+       (emms-cache-set-function nil))
+    (let-alist data
+      (let ((track (emms-track 'streamlist .url))
+           (metadata (list .name .url 1 'streamlist)))
+       (emms-track-set track 'metadata metadata)
+       track))))
+
+(defun emms-radio-browser-display-tracks (tracks)
+  "Load TRACKS into new playlist buffer and display same."
+  (let ((buf (emms-playlist-new "*EMMS radio-browser search results*")))
+    (with-current-buffer buf
+      (mapc #'emms-playlist-insert-track tracks)
+      (emms-playlist-select (point-min))
+      (emms-playlist-mode-center-current)
+      ;; (emms-playlist-set-playlist-buffer)
+      (switch-to-buffer buf))))
+
+
+(defun emms-radio-browser-query-callback (status &optional cbargs)
+  "Process server response and display playlist of results.
+
+Mandatory callback arguments STATUS and CBARGS are ignored."
+  ;; Check response OK.
+  (ignore status cbargs)
+  (set-buffer-multibyte t)
+  (emms-radio-browser-check-response)
+  ;; Slurp json
+  (goto-char (point-min))
+  (let ((response (ignore-errors
+                   (re-search-forward "\n\n")
+                   (json-read))))
+    (kill-buffer)
+    (if (seq-empty-p response)
+       (message "emms-radio-browser: No matches found!")
+      (emms-radio-browser-display-tracks
+       (mapcar #'emms-radio-browser-json-to-track response)))))
+
+
+;;* Entry points
+;;;###autoload
+(defun emms-radio-browser-search-by-name (name)
+  "Search radio-browser for stations matching NAME.
+
+Switches to an EMMS playlist containing the results."
+  (interactive "sSearch for station name: ")
+  (emms-radio-browser-query-api (emms-radio-browser-search-by-name-payload 
name)
+                               emms-radio-browser-search-endpoint))
+
+;;;###autoload
+(defun emms-radio-browser-search-by-url (url)
+  "Search radio-browser for stations matching URL.
+
+Switches to an EMMS playlist containing the results."
+  (interactive "sSearch for URL: ")
+  (emms-radio-browser-query-api (list (cons 'url url))
+                               emms-radio-browser-url-endpoint))
+
+;; Finally load the transient for making a full search.  This was
+;;conditionally defined above.  We wrap in in a function to get the autoload.
+;;;###autoload
+(defun emms-radio-browser-full-search ()
+  "Construct a search query by filling in a form.
+
+Optionally dispatch it to the radio-browser server and switch to an
+EMMS playlist of results."
+  (interactive)
+  (call-interactively #'emms-radio-browser-full-search-prefix t))
+
+
+(provide 'emms-radio-browser)
+;;; emms-radio-browser.el ends here

Reply via email to