branch: externals/emms
commit 592d76cb0e0a95ffe27f88b7e455626630e1e8b3
Author: Yoni Rabkin <y...@gnu.org>
Commit: Yoni Rabkin <y...@gnu.org>

    Add emms-idapi, a framework for searching online music databases.
    
    At this point with a limited browser for search results and support
    for the MusicBrainz service.
---
 emms-idapi-browser.el     | 214 +++++++++++++++++++++++++++++++++++++++++++
 emms-idapi-musicbrainz.el | 229 ++++++++++++++++++++++++++++++++++++++++++++++
 emms-idapi.el             |  55 +++++++++++
 emms-setup.el             |   4 +-
 4 files changed, 501 insertions(+), 1 deletion(-)

diff --git a/emms-idapi-browser.el b/emms-idapi-browser.el
new file mode 100644
index 0000000000..84b6386893
--- /dev/null
+++ b/emms-idapi-browser.el
@@ -0,0 +1,214 @@
+;;; emms-idapi-browser.el --- EMMS Music ID API support  -*- lexical-binding: 
t; -*-
+;;
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Yoni Rabkin <y...@gnu.org>
+
+;; This file is part of EMMS.
+
+;; 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:
+;;
+
+(defvar emms-idapi-browser-name
+  "Emms Search Browser"
+  "Name of the search browser buffer")
+
+(defvar emms-idapi-browser-buffer
+  nil
+  "Search browser buffer")
+
+(defvar emms-idapi-browser-mode-hook nil
+  "Emms search browser mode hook.")
+
+(defvar emms-idapi-browser-field-alist
+  '(("artist" . info-artist)
+    ("album"  . info-album)
+    ("track"  . info-title))
+  "Association list of readable fields and track properties.")
+
+;;; Code:
+(require 'emms-idapi)
+
+
+;;; ------------------------------------------------------------------
+;;; Search Mode
+;;; ------------------------------------------------------------------
+(defun emms-idapi-browser-get-buffer ()
+  "Get/create and return `emms-idapi-browser-mode' buffer."
+  (when (or (not emms-idapi-browser-buffer)
+           (not (buffer-live-p emms-idapi-browser-buffer)))
+    (with-current-buffer (get-buffer-create emms-idapi-browser-name)
+      (when (not (equal major-mode 'emms-idapi-browser-mode))
+       (emms-idapi-browser-mode))))
+  emms-idapi-browser-buffer)
+
+(defvar emms-idapi-browser-mode-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map (kbd "q") #'bury-buffer)
+    map)
+  "Keymap for `emms-idapi-browser-mode'.")
+
+(defun emms-idapi-browser-mode ()
+  "A major mode for the Emms search browser.
+\\{emms-idapi-browser-mode-map}"
+  (interactive)
+
+  (use-local-map emms-idapi-browser-mode-map)
+
+  (setq major-mode 'emms-idapi-browser-mode
+        mode-name "Emms-Search-Browser"
+       buffer-read-only t
+       emms-idapi-browser-buffer (current-buffer))
+
+  (run-hooks 'emms-idapi-browser-mode-hook))
+
+
+;;; ------------------------------------------------------------------
+;;; Call
+;;; ------------------------------------------------------------------
+(defun emms-idapi-browser-track-at ()
+  "Return a copy of the track at point."
+  (let* ((originial-track (emms-playlist-track-at (point)))
+        (track (copy-sequence originial-track)))
+    (when (not (emms-track-p track))
+      (error "could not read Emms track at point"))
+    track))
+
+(defun emms-idapi-browser-search-artist (track)
+  "Search for the artist of TRACK."
+  (let ((artist (or (alist-get 'info-artist track)
+                   (alist-get 'info-albumartist track))))
+    (list
+     (cons 'info-artist (read-string "search for artist: " artist)))))
+
+(defun emms-idapi-browser-search-album (track)
+  "Search for the album of TRACK."
+  (let ((album (alist-get 'info-album track)))
+    (list
+     (cons 'info-album (read-string "search for album: " album)))))
+
+(defun emms-idapi-browser-search-album-artist (track)
+  "Search for both artist and album of TRACK."
+  (let ((artist (or (alist-get 'info-artist track)
+                   (alist-get 'info-albumartist track)))
+       (album (alist-get 'info-album track))
+       search-album)
+    (setq search-album (read-string "search for album: " album))
+    (list
+     (cons 'info-album search-album)
+     (cons 'info-artist (read-string
+                        (format "search for album \"%s\" by artist: " 
search-album)
+                        artist)))))
+
+(defun emms-idapi-browser-search-artist-at ()
+  "Search for the artist of the track at point."
+  (interactive)
+  (emms-idapi-browser-show
+   (emms-idapi-search emms-idapi-service
+                     (emms-idapi-browser-search-artist
+                      (emms-playlist-track-at (point))))))
+
+(defun emms-idapi-browser-search-album-at ()
+  "Search for the album of the track at point."
+  (interactive)
+  (emms-idapi-browser-show
+   (emms-idapi-search emms-idapi-service
+                     (emms-idapi-browser-search-album
+                      (emms-playlist-track-at (point))))))
+
+(defun emms-idapi-browser-search-album-artist-at ()
+  "Search for the album and artist of the track at point."
+  (interactive)
+  (emms-idapi-browser-show
+   (emms-idapi-search emms-idapi-service
+                     (emms-idapi-browser-search-album-artist
+                      (emms-playlist-track-at (point))))))
+
+;;; ------------------------------------------------------------------
+;;; Response
+;;; ------------------------------------------------------------------
+(defun emms-idapi-browser-print-header (header)
+  "Print the material for the search HEADER."
+  (let ((artist (alist-get 'info-artist header))
+       (album (alist-get 'info-album header))
+       (service (alist-get emms-idapi-service emms-idapi-services-alist)))
+    (when (not (or artist album))
+      (error "could not read header: %s" header))
+    (insert (format "service: %s (%s)\n"
+                   (alist-get 'name service)
+                   (alist-get 'website service)))
+    (when artist
+      (insert (format "artist:  %s\n" artist)))
+    (when album
+      (insert (format "album:   %s\n" album)))
+    (insert "\n")))
+
+(defun emms-idapi-browser-entry-packaging (entry)
+  "Print packaging information for ENTRY."
+  (let ((packaging (alist-get 'info-packaging entry)))
+    (if (and packaging
+            (not (string= "None" packaging)))
+       (format ", %s" packaging)
+      "")))
+
+(defun emms-idapi-browser-print-entry (entry)
+  "Print ENTRY."
+  (cond ((equal 'info-release (alist-get 'type entry))
+        (insert (format "\"%s\" by %s%s\n"
+                        (alist-get 'info-album entry)
+                        (alist-get 'info-artist entry)
+                        (if (alist-get 'info-date entry)
+                            (format ", released on %s" (alist-get 'info-date 
entry))
+                          "")))
+        (insert (format "%s tracks%s%s\n\n"
+                        (alist-get 'info-track-count entry)
+                        (emms-idapi-browser-entry-packaging entry)
+                        (if (alist-get 'info-country entry)
+                            (format ", (%s)" (alist-get 'info-country entry))
+                          ""))))
+       ((equal 'info-track-artist (alist-get 'type entry))
+        (insert (format "%s%s%s\n\n"
+                        (alist-get 'info-artist entry)
+                        (if (alist-get 'info-country entry)
+                            (format " (%s) " (alist-get 'info-country entry))
+                          "")
+                        (let ((begin (alist-get 'begin (alist-get 'info-time 
entry)))
+                              (end (alist-get 'end (alist-get 'info-time 
entry))))
+                          (format "%s%s"
+                                  (if begin begin "")
+                                  (if end (format " - %s, " end) ""))))))
+       (t (insert (format  "%s\n" entry)))))
+
+(defun emms-idapi-browser-show (response)
+  "Display RESPONSE in a search buffer."
+  (let ((buffer (emms-idapi-browser-get-buffer)))
+    (pop-to-buffer buffer)
+    (let ((inhibit-read-only t))
+      (erase-buffer)
+      (insert "Emms Music Search Query\n\n")
+      (emms-idapi-browser-print-header (car response))
+      (insert (format "%d results\n\n" (length (cdr response))))
+      (dolist (e (cdr response))
+       (emms-idapi-browser-print-entry e)))))
+
+
+(provide 'emms-idapi-browser)
+
+;;; emms-idapi-browser.el ends here
diff --git a/emms-idapi-musicbrainz.el b/emms-idapi-musicbrainz.el
new file mode 100644
index 0000000000..4a5554979b
--- /dev/null
+++ b/emms-idapi-musicbrainz.el
@@ -0,0 +1,229 @@
+;;; emms-idapi-musicbrainz.el --- EMMS MusicBrainz API support  -*- 
lexical-binding: t; truncate-lines: t; -*-
+;;
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Yoni Rabkin <y...@gnu.org>
+
+;; This file is part of EMMS.
+
+;; 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:
+;;
+
+;;; Code:
+(require 'mm-bodies)
+(require 'url-vars)
+(require 'json)
+(require 'emms)
+
+
+(defvar emms-idapi-musicbrainz-url-timeout-seconds 7
+  "Number of seconds to wait before an API call times out.")
+
+(defvar emms-idapi-musicbrainz-url-buffer nil
+  "Buffer to store `url' response.")
+
+(defvar emms-idapi-musicbrainz-response-limit 30
+  "Maximum number of responses to ask for. Maximum is 100.")
+
+(defconst emms-idapi-musicbrainz-root-url "https://musicbrainz.org/ws/2/";
+  "The API root URL for the MusicBrainz service.")
+
+(defvar emms-idapi-query-local nil
+  "Cross-call storage for search query.")
+(make-variable-buffer-local 'emms-idapi-query-local)
+
+(defconst emms-idapi-musicbrainz-search-string-map
+  '((info-artist      . "artist")
+    (info-albumartist . "artist")
+    (info-title       . "recording")
+    (info-album       . "release"))
+  "Mapping between MusicBrainz API TYPES and Emms fields.")
+
+
+;;; ------------------------------------------------------------------
+;;; Response
+;;; ------------------------------------------------------------------
+(defun emms-idapi-musicbrainz-decode (response)
+  "Decode the strings in response."
+  (mapc
+   (lambda (elt)
+     (when (stringp (cdr elt))
+       (setf (cdr elt) (mm-decode-string (cdr elt) 'utf-8))))
+   response))
+
+(defun emms-idapi-musicbrainz-read-artist (artist)
+  "Return a track from the MusicBrainz ARTIST."
+  (when (not (alist-get 'id artist))
+    (error "could not parse from: %s" artist))
+  `(*track* (search-backend . musicbrainz)
+            (type           . info-track-artist)
+           (name           . nil)
+           (info-arid      . ,(alist-get 'id artist))
+           (info-artist    . ,(alist-get 'name artist))
+           (info-type      . ,(alist-get 'type artist))
+           (info-country   . ,(alist-get 'country artist))
+           (info-time      . ,(alist-get 'life-span artist))))
+
+(defun emms-idapi-musicbrainz-read-release (release)
+  "Return a track from the MusicBrainz RELEASE."
+  (when (not (alist-get 'id release))
+    (error "could not parse from: %s" release))
+  `(*track* (search-backend . musicbrainz)
+           (type                . info-release)
+           (name                . nil)
+           (info-release-id     . ,(alist-get 'id release))
+           (info-artist         . ,(alist-get 'name (elt (alist-get 
'artist-credit release) 0)))
+           (info-album          . ,(alist-get 'title release))
+           (info-status         . ,(alist-get 'status release))
+           (info-disambiguation . ,(alist-get 'disambiguation release))
+           (info-packaging      . ,(alist-get 'packaging release))
+           (info-date           . ,(alist-get 'date release))
+           (info-country        . ,(alist-get 'country release))
+           (info-track-count    . ,(alist-get 'track-count release))))
+
+(defun emms-idapi-musicbrainz-read-recording (recording)
+  "Return a track from the MusicBrainz RECORDING."
+  (when (not (alist-get 'id recording))
+    (error "could not parse from: %s" recording))
+  (let ((length-ms (alist-get 'length recording)))
+    `(*track* (search-backend . musicbrainz)
+             (type                  . info-recording)
+             (name                  . ,(alist-get 'title recording))
+             (info-playing-time     . ,(floor (/ length-ms 1000)))
+             (info-playing-time-min . ,(floor (/ (/ length-ms 1000) 60)))
+             (info-playing-time-sec . ,(% (floor (/ length-ms 1000)) 60))
+             (info-recording-id     . ,(alist-get 'id recording))
+             (info-album            . ,(alist-get 'title recording))
+             (info-length-ms        . ,length-ms))))
+
+(defun emms-idapi-musicbrainz-process-type-dispatch (response)
+  "Call the appropriate processing function for RESPONSE."
+  (let ((process-f (cond ((alist-get 'artists response) 
#'emms-idapi-musicbrainz-read-artist)
+                        ((alist-get 'releases response) 
#'emms-idapi-musicbrainz-read-release)
+                        ((alist-get 'recordings response) 
#'emms-idapi-musicbrainz-read-recording)
+                        (t (error "unhandled response type %s" response))))
+       ;; the actual items without header data
+       (elements (cdr (nth 3 response))))
+    (append (alist-get 'query response)
+           (mapcar
+            #'(lambda (e)
+                (emms-idapi-musicbrainz-decode
+                 (cdr (funcall process-f e))))
+            elements))))
+
+(defun emms-idapi-musicbrainz-process-json (buffer)
+  "Return the sexp form of the json in BUFFER."
+  (with-current-buffer buffer
+    (goto-char (point-min))
+    (let ((header-max (if (not (re-search-forward "\n\n" (point-max)))
+                         (error "cannot find header")
+                       (point))))
+      (append
+       (json-read-from-string
+       (mm-decode-string
+        (buffer-substring header-max (point-max))
+        'utf-8))
+       `((query . ,(list emms-idapi-query-local)))))))
+
+(defun emms-idapi-musicbrainz-process (buffer)
+  "Process response stored in BUFFER. Return BUFFER."
+  (when (or (not buffer)
+           (not (bufferp buffer)))
+    (error "cannot access response buffer"))
+  (with-current-buffer buffer
+    (goto-char (point-min))
+    (when (not (re-search-forward "HTTP/1.1 200 OK" (line-end-position) t))
+      (error "not a valid HTTP response from server"))
+    buffer))
+
+
+;;; ------------------------------------------------------------------
+;;; Call
+;;; ------------------------------------------------------------------
+(defun emms-idapi-musicbrainz-call (search-term-alist)
+  "Make a call into search service based on SEARCH-TERM-ALIST."
+  (let (buffer
+       ;; Robert Kaye from MusicBrainz said on the 21st of February,
+       ;; 2024, that this format of user agent string is good.
+       (url-user-agent (concat "Emacs_Emms/"
+                               emms-version
+                               " (https://www.gnu.org/software/emms/)"))
+       (url-string (emms-idapi-musicbrainz-make-search-string
+                    search-term-alist)))
+    (setq buffer (url-retrieve-synchronously
+                 url-string
+                 t t
+                 emms-idapi-musicbrainz-url-timeout-seconds))
+    (if (not buffer)
+       (error "call to musicbrainz API timeout or returned nothing")
+      (with-current-buffer buffer
+       (setq emms-idapi-query-local search-term-alist))
+      (setq emms-idapi-musicbrainz-url-buffer buffer))))
+
+(defun emms-idapi-musicbrainz-get-search-string (field)
+  "Return the search string associated with FIELD."
+  (let ((string (alist-get field emms-idapi-musicbrainz-search-string-map)))
+    (when (not string)
+      (error "no associated string for \"%s\" field" field))
+    string))
+
+(defun emms-idapi-musicbrainz-make-search-string (term-alist)
+  (let ((artist  (or (alist-get 'info-artist term-alist)
+                    (alist-get 'info-albumartist term-alist)))
+       (release (alist-get 'info-album  term-alist))
+       (track   (alist-get 'info-title  term-alist))
+       (reid    (alist-get 'reid        term-alist))
+       (arid    (alist-get 'arid        term-alist)))
+    (concat emms-idapi-musicbrainz-root-url
+
+           (cond ((and artist (not release))
+                  (format "artist/?query=%s" (url-encode-url (concat "\"" 
artist "\""))))
+                 (release
+                  (format "release/?query=release:%s%s%s"
+                          (url-encode-url (concat "\"" release "\""))
+                          (if artist (url-encode-url (concat " AND artist:\"" 
artist "\"")) "")
+                          (if arid (concat (url-encode-url " AND ") "arid:" 
arid) "")))
+                 (track
+                  (format "recording?query=%sreid:%s"
+                          (url-encode-url (concat "\"" track "\""))
+                          reid))
+                 (t (error "unhandled field %s" term-alist)))
+
+           (format "&limit=%d&fmt=json" 
emms-idapi-musicbrainz-response-limit))))
+
+
+;;; ------------------------------------------------------------------
+;;; Interface
+;;; ------------------------------------------------------------------
+(defun emms-idapi-musicbrainz-search (search-term-alist)
+  "IDAPI interface function for searching MusicBrainz service.
+
+ARID limits the search to a MusicBrainz artist id."
+  (when (not (listp search-term-alist))
+    (error "%s is not a list" search-term-alist))
+  (emms-idapi-musicbrainz-process-type-dispatch
+   (emms-idapi-musicbrainz-process-json
+    (emms-idapi-musicbrainz-process
+     (emms-idapi-musicbrainz-call search-term-alist)))))
+
+
+(provide 'emms-idapi-musicbrainz)
+
+;;; emms-idapi-musicbrainz.el ends here
diff --git a/emms-idapi.el b/emms-idapi.el
new file mode 100644
index 0000000000..7898540f49
--- /dev/null
+++ b/emms-idapi.el
@@ -0,0 +1,55 @@
+;;; emms-idapi.el --- EMMS Music ID API support  -*- lexical-binding: t; -*-
+;;
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Yoni Rabkin <y...@gnu.org>
+
+;; This file is part of EMMS.
+
+;; 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:
+;;
+
+;;; Code:
+(require 'emms-idapi-musicbrainz)
+
+
+(defvar emms-idapi-services-alist
+  '((musicbrainz . ((search-f . emms-idapi-musicbrainz-search)
+                   (name     . "MusicBrainz")
+                   (website  . "https://musicbrainz.org/";))))
+  "Association list of services supported by IDAPI.")
+
+(defvar emms-idapi-service nil
+  "The music search service currently in use.")
+
+
+(defun emms-idapi-search (service search-term-alist)
+  "Search against SERVICE for SEARCH-TERM-ALIST."
+  (let ((search-function (alist-get 'search-f
+                                   (alist-get service
+                                              emms-idapi-services-alist))))
+    (if (not search-function)
+       (error "`%s' is an unsupported service." service))
+    (funcall search-function search-term-alist)))
+          
+
+(provide 'emms-idapi)
+
+;;; emms-idapi.el ends here
diff --git a/emms-setup.el b/emms-setup.el
index 2ce6ed57b3..088b41d632 100644
--- a/emms-setup.el
+++ b/emms-setup.el
@@ -128,7 +128,9 @@ the stable features which come with the Emms distribution."
     (require 'emms-playlist-limit)
     (require 'emms-librefm-scrobbler)
     (require 'emms-librefm-stream)
-    (require 'emms-mpris))
+    (require 'emms-mpris)
+    (require 'emms-idapi-musicbrainz)
+    (require 'emms-idapi-browser))
   ;; setup
   (setq emms-playlist-default-major-mode #'emms-playlist-mode)
   (add-to-list 'emms-track-initialize-functions #'emms-info-initialize-track)

Reply via email to