I started to play with the https://www.radio-browser.info API and built a radio station browser for EMMS which I attach.
There are three entry points emms-radio-browser-search-by-name emms-radio-browser-search-by-url emms-radio-browser-full-search All search the radio-browser database and return a playlist of results. The last of these needs the (built-in since v28.1) transient package. It has only been lightly tested. If you think this is a worthwhile addition to EMMS, I can add it to the git repo and write some documentation... ---Fran On Thu, 26 Dec 2024 at 17:11, Fran Burstall (Gmail) <[email protected]> wrote: > I have been playing with emms-streams and with > > (setopt emms-player-mpv-update-metadata t) > > it is very capable. > > One mild pain point however is that adding a new stream with > 'emms-add-streamlist' and friends gives a streamlist with less information > than the built-in streamlists: it lacks the metadata field which is useful > for getting the station name (which can then be fed to a track-description > function to make for a more informative display in the playlist buffer). > > Of course, one can populate such a field by hand and this is what I have > been doing but There Must Be A Better Way. This is the sort of thing that > the unimplemented emms-streams-info.el could be doing but I understand that > querying the url for such information is a bit of a nightmare (does the > stream have ICY tags etc). There have been previous discussions on this > list about this. > > However, there seems to be an alternative: https://www.radio-browser.info > is a free (as in freedom, as far as I can tell) repository of station > information with an API. Perhaps one could query this to get metadata > about the stream? > > One could also imagine other uses of this data like a radio station > browser in EMMS or being able to add streams by name rather than url... > > Thoughts? Worth pursuing? > > ---Fran > > >
;;; emms-radio-browser.el --- EMMS client for radio-brower API -*- lexical-binding: t; -*- ;; Copyright (C) 2025 Fran Burstall ;; Author: Fran Burstall <[email protected]> ;; Keywords: multimedia ;; This program 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 of the License, or ;; (at your option) any later version. ;; This program 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 this program. If not, see <https://www.gnu.org/licenses/>. ;;; 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-seach-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 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)) ;; Look in the "Entry points" section for the prefix that uses all ;; this. ;;** 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) (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 here is the transient prefix for making a full search. ;;;###autoload (transient-define-prefix 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." :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)]) (provide 'emms-radio-browser) ;;; emms-radio-browser.el ends here
