| 1 | ;;; emms-idapi-musicbrainz.el --- EMMS MusicBrainz API support -*- lexical-binding: t; truncate-lines: t; -*- |
| 2 | ;; |
| 3 | |
| 4 | ;; Copyright (C) 2024 Free Software Foundation, Inc. |
| 5 | |
| 6 | ;; Author: Yoni Rabkin <yrk@gnu.org> |
| 7 | |
| 8 | ;; This file is part of EMMS. |
| 9 | |
| 10 | ;; EMMS is free software; you can redistribute it and/or modify it |
| 11 | ;; under the terms of the GNU General Public License as published by |
| 12 | ;; the Free Software Foundation; either version 3, or (at your option) |
| 13 | ;; any later version. |
| 14 | |
| 15 | ;; EMMS is distributed in the hope that it will be useful, but WITHOUT |
| 16 | ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY |
| 17 | ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public |
| 18 | ;; License for more details. |
| 19 | |
| 20 | ;; You should have received a copy of the GNU General Public License |
| 21 | ;; along with EMMS; see the file COPYING. If not, write to the Free |
| 22 | ;; Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, |
| 23 | ;; MA 02110-1301, USA. |
| 24 | |
| 25 | |
| 26 | ;;; Commentary: |
| 27 | ;; |
| 28 | |
| 29 | ;;; Code: |
| 30 | (require 'mm-bodies) |
| 31 | (require 'url-vars) |
| 32 | (require 'json) |
| 33 | (require 'emms) |
| 34 | |
| 35 | |
| 36 | (defvar emms-idapi-musicbrainz-url-timeout-seconds 7 |
| 37 | "Number of seconds to wait before an API call times out.") |
| 38 | |
| 39 | (defvar emms-idapi-musicbrainz-url-buffer nil |
| 40 | "Buffer to store `url' response.") |
| 41 | |
| 42 | (defvar emms-idapi-musicbrainz-response-limit 100 |
| 43 | "Maximum number of responses to ask for. Maximum is 100.") |
| 44 | |
| 45 | (defconst emms-idapi-musicbrainz-root-url "https://musicbrainz.org/ws/2/" |
| 46 | "The API root URL for the MusicBrainz service.") |
| 47 | |
| 48 | (defvar emms-idapi-query-local nil |
| 49 | "Cross-call storage for search query.") |
| 50 | (make-variable-buffer-local 'emms-idapi-query-local) |
| 51 | |
| 52 | (defvar emms-idapi-musicbrainz-debug-buffer-name |
| 53 | " *Emms MusicBrainz Debug Buffer*" |
| 54 | "Name of debug buffer for MusicBrainz url responses.") |
| 55 | |
| 56 | (defvar emms-idapi-musicbrainz-debug-buffer nil |
| 57 | "Debug buffer for MusicBrainz url responses.") |
| 58 | |
| 59 | |
| 60 | (defconst emms-idapi-musicbrainz-search-string-map |
| 61 | '((info-artist . "artist") |
| 62 | (info-albumartist . "artist") |
| 63 | (info-title . "recording") |
| 64 | (info-album . "release")) |
| 65 | "Mapping between MusicBrainz API TYPES and Emms fields.") |
| 66 | |
| 67 | |
| 68 | ;;; ------------------------------------------------------------------ |
| 69 | ;;; Response |
| 70 | ;;; ------------------------------------------------------------------ |
| 71 | (defun emms-idapi-musicbrainz-decode (response) |
| 72 | "Decode the strings in response." |
| 73 | (mapc |
| 74 | (lambda (elt) |
| 75 | (when (stringp (cdr elt)) |
| 76 | (setf (cdr elt) (mm-decode-string (cdr elt) 'utf-8)))) |
| 77 | response)) |
| 78 | |
| 79 | (defun emms-idapi-musicbrainz-read-artist (artist) |
| 80 | "Return a track from the MusicBrainz ARTIST." |
| 81 | (when (not (alist-get 'id artist)) |
| 82 | (error "could not parse from: %s" artist)) |
| 83 | `(*track* (search-backend . musicbrainz) |
| 84 | (type . idapi-artist) |
| 85 | (name . nil) |
| 86 | (idapi-artist-id . ,(list (cons 'musicbrainz (alist-get 'id artist)))) |
| 87 | (info-artist . ,(alist-get 'name artist)) |
| 88 | (info-gender . ,(alist-get 'gender artist)) |
| 89 | (info-type . ,(alist-get 'type artist)) |
| 90 | (info-country . ,(alist-get 'country artist)) |
| 91 | (info-area-type . ,(alist-get 'type (cddr (assoc 'area artist)))) |
| 92 | (info-area-country . ,(alist-get 'name (cddr (assoc 'area artist)))) |
| 93 | (info-aliases . ,(list |
| 94 | (seq-map |
| 95 | (lambda (elt) |
| 96 | (mm-decode-string |
| 97 | (alist-get 'sort-name elt) 'utf-8)) |
| 98 | (alist-get 'aliases artist)))) |
| 99 | (info-tags . ,(list |
| 100 | (seq-map |
| 101 | (lambda (elt) |
| 102 | (mm-decode-string |
| 103 | (alist-get 'name elt) 'utf-8)) |
| 104 | (alist-get 'tags artist)))) |
| 105 | (info-time . ,(alist-get 'life-span artist)))) |
| 106 | |
| 107 | (defun emms-idapi-musicbrainz-read-release (release) |
| 108 | "Return a track from the MusicBrainz RELEASE." |
| 109 | (when (not (alist-get 'id release)) |
| 110 | (error "could not parse from: %s" release)) |
| 111 | `(*track* (search-backend . musicbrainz) |
| 112 | (type . idapi-release) |
| 113 | (name . nil) |
| 114 | (idapi-release-id . ,(list (cons 'musicbrainz (alist-get 'id release)))) |
| 115 | (info-artist . ,(alist-get 'name (elt (alist-get 'artist-credit release) 0))) |
| 116 | (info-album . ,(alist-get 'title release)) |
| 117 | (info-status . ,(alist-get 'status release)) |
| 118 | (info-disambiguation . ,(alist-get 'disambiguation release)) |
| 119 | (info-packaging . ,(alist-get 'packaging release)) |
| 120 | (info-date . ,(alist-get 'date release)) |
| 121 | (info-country . ,(alist-get 'country release)) |
| 122 | (info-track-count . ,(alist-get 'track-count release)))) |
| 123 | |
| 124 | (defun emms-idapi-musicbrainz-read-recording (recording) |
| 125 | "Return a track from the MusicBrainz RECORDING." |
| 126 | (when (not (alist-get 'id recording)) |
| 127 | (error "could not parse from: %s" recording)) |
| 128 | (let ((length-ms (or (alist-get 'length recording) 0))) |
| 129 | `(*track* (search-backend . musicbrainz) |
| 130 | (type . idapi-recording) |
| 131 | (name . ,(alist-get 'title recording)) |
| 132 | (info-playing-time . ,(floor (/ length-ms 1000))) |
| 133 | (info-playing-time-min . ,(floor (/ (/ length-ms 1000) 60))) |
| 134 | (info-playing-time-sec . ,(% (floor (/ length-ms 1000)) 60)) |
| 135 | (info-recording-id . ,(alist-get 'id recording)) |
| 136 | (idapi-releases . ,(seq-map |
| 137 | (lambda (elt) |
| 138 | (emms-idapi-musicbrainz-read-release elt)) |
| 139 | (alist-get 'releases recording))) |
| 140 | (info-length-ms . ,length-ms)))) |
| 141 | |
| 142 | (defun emms-idapi-musicbrainz-process-type-dispatch (response) |
| 143 | "Call the appropriate processing function for RESPONSE." |
| 144 | (let ((process-f (cond ((alist-get 'artists response) #'emms-idapi-musicbrainz-read-artist) |
| 145 | ((alist-get 'releases response) #'emms-idapi-musicbrainz-read-release) |
| 146 | ((alist-get 'recordings response) #'emms-idapi-musicbrainz-read-recording) |
| 147 | (t (error "unhandled response type %s" response)))) |
| 148 | ;; the actual items without header data |
| 149 | (elements (cdr (nth 3 response))) |
| 150 | (debug-buffer (get-buffer-create emms-idapi-musicbrainz-debug-buffer-name))) |
| 151 | (setq emms-idapi-musicbrainz-debug-buffer debug-buffer) |
| 152 | (with-current-buffer debug-buffer |
| 153 | (erase-buffer) |
| 154 | (insert (format "%s" response))) |
| 155 | (append (alist-get 'query response) |
| 156 | (mapcar |
| 157 | #'(lambda (e) |
| 158 | (emms-idapi-musicbrainz-decode |
| 159 | (cdr (funcall process-f e)))) |
| 160 | elements)))) |
| 161 | |
| 162 | (defun emms-idapi-musicbrainz-process-json (buffer) |
| 163 | "Return the sexp form of the json in BUFFER." |
| 164 | (with-current-buffer buffer |
| 165 | (goto-char (point-min)) |
| 166 | (let ((header-max (if (not (re-search-forward "\n\n" (point-max))) |
| 167 | (error "cannot find header") |
| 168 | (point)))) |
| 169 | (append |
| 170 | (json-read-from-string |
| 171 | (mm-decode-string |
| 172 | (buffer-substring header-max (point-max)) |
| 173 | 'utf-8)) |
| 174 | `((query . ,(list emms-idapi-query-local))))))) |
| 175 | |
| 176 | (defun emms-idapi-musicbrainz-process (buffer) |
| 177 | "Process response stored in BUFFER. Return BUFFER." |
| 178 | (when (or (not buffer) |
| 179 | (not (bufferp buffer))) |
| 180 | (error "cannot access response buffer")) |
| 181 | (with-current-buffer buffer |
| 182 | (goto-char (point-min)) |
| 183 | (when (not (re-search-forward "HTTP/1.1 200 OK" (line-end-position) t)) |
| 184 | (error "not a valid HTTP response from server")) |
| 185 | buffer)) |
| 186 | |
| 187 | |
| 188 | ;;; ------------------------------------------------------------------ |
| 189 | ;;; Call |
| 190 | ;;; ------------------------------------------------------------------ |
| 191 | (defun emms-idapi-musicbrainz-call (search-term-alist) |
| 192 | "Make a call into search service based on SEARCH-TERM-ALIST." |
| 193 | (let (buffer |
| 194 | ;; Robert Kaye from MusicBrainz said on the 21st of February, |
| 195 | ;; 2024, that this format of user agent string is good. |
| 196 | (url-user-agent (concat "Emacs_Emms/" |
| 197 | emms-version |
| 198 | " (https://www.gnu.org/software/emms/)")) |
| 199 | (url-string (emms-idapi-musicbrainz-make-search-string |
| 200 | search-term-alist))) |
| 201 | (setq buffer (url-retrieve-synchronously |
| 202 | url-string |
| 203 | t t |
| 204 | emms-idapi-musicbrainz-url-timeout-seconds)) |
| 205 | (if (not buffer) |
| 206 | (error "call to musicbrainz API timeout or returned nothing") |
| 207 | (with-current-buffer buffer |
| 208 | (setq emms-idapi-query-local search-term-alist)) |
| 209 | (setq emms-idapi-musicbrainz-url-buffer buffer)))) |
| 210 | |
| 211 | (defun emms-idapi-musicbrainz-get-search-string (field) |
| 212 | "Return the search string associated with FIELD." |
| 213 | (let ((string (alist-get field emms-idapi-musicbrainz-search-string-map))) |
| 214 | (when (not string) |
| 215 | (error "no associated string for \"%s\" field" field)) |
| 216 | string)) |
| 217 | |
| 218 | (defun emms-idapi-musicbrainz-make-search-string (term-alist) |
| 219 | (let ((artist (or (alist-get 'info-artist term-alist) |
| 220 | (alist-get 'info-albumartist term-alist))) |
| 221 | (release (alist-get 'info-album term-alist)) |
| 222 | (title (alist-get 'info-title term-alist)) |
| 223 | ;; (reid (alist-get 'reid term-alist)) |
| 224 | ;; (arid (alist-get 'arid term-alist)) |
| 225 | ) |
| 226 | (concat emms-idapi-musicbrainz-root-url |
| 227 | |
| 228 | (cond ((and title |
| 229 | artist) |
| 230 | (format "recording/?query=recording:%s%sartist:%s" |
| 231 | (url-encode-url (concat "\"" title "\"")) |
| 232 | (url-encode-url " AND ") |
| 233 | (url-encode-url (concat "\"" artist "\"")))) |
| 234 | |
| 235 | ((and artist |
| 236 | (not release) |
| 237 | (not title)) |
| 238 | (format "artist/?query=%s" (url-encode-url (concat "\"" artist "\"")))) |
| 239 | |
| 240 | ;; Will only work if the browser supplies a meaningful musicbrainz ARID |
| 241 | ;; |
| 242 | ;; (release |
| 243 | ;; (format "release/?query=release:%s%s%s" |
| 244 | ;; (url-encode-url (concat "\"" release "\"")) |
| 245 | ;; (if artist (url-encode-url (concat " AND artist:\"" artist "\"")) "") |
| 246 | ;; (if arid (concat (url-encode-url " AND ") "arid:" arid) ""))) |
| 247 | |
| 248 | (t (error "unhandled field %s" term-alist))) |
| 249 | (format "&limit=%d&fmt=json" emms-idapi-musicbrainz-response-limit)))) |
| 250 | |
| 251 | |
| 252 | ;;; ------------------------------------------------------------------ |
| 253 | ;;; Interface |
| 254 | ;;; ------------------------------------------------------------------ |
| 255 | (defun emms-idapi-musicbrainz-search (search-term-alist) |
| 256 | "IDAPI interface function for searching MusicBrainz service. |
| 257 | |
| 258 | ARID limits the search to a MusicBrainz artist id." |
| 259 | (when (not (listp search-term-alist)) |
| 260 | (error "%s is not a list" search-term-alist)) |
| 261 | (emms-idapi-musicbrainz-process-type-dispatch |
| 262 | (emms-idapi-musicbrainz-process-json |
| 263 | (emms-idapi-musicbrainz-process |
| 264 | (emms-idapi-musicbrainz-call search-term-alist))))) |
| 265 | |
| 266 | |
| 267 | (provide 'emms-idapi-musicbrainz) |
| 268 | |
| 269 | ;;; emms-idapi-musicbrainz.el ends here |