1 ;;; listenbrainz.el --- ListenBrainz API interface -*- coding: utf-8; lexical-binding: t -*-
5 ;; Author: nik gaffney <nik@fo.am>
8 ;; Package-Requires: ((emacs "27.1") (request "0.3"))
9 ;; Keywords: music, scrobbling, multimedia
10 ;; URL: https://github.com/zzkt/listenbrainz
12 ;; This file is not part of GNU Emacs.
14 ;; This program is free software; you can redistribute it and/or modify
15 ;; it under the terms of the GNU General Public License as published by
16 ;; the Free Software Foundation, either version 3 of the License, or
17 ;; (at your option) any later version.
19 ;; This program is distributed in the hope that it will be useful,
20 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
21 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 ;; GNU General Public License for more details.
24 ;; You should have received a copy of the GNU General Public License
25 ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
30 ;; An interface to ListenBrainz, a project to store a record of the music that
31 ;; you listen to. The listening data, can be used to provide statistics,
32 ;; recommendations and general exploration.
34 ;; The package can be used programmatically (e.g. from a music player) to auto
35 ;; submit listening data `listenbrainz-submit-listen'. There are other entrypoints
36 ;; for reading user stats such as `listenbrainz-stats-artists' or
37 ;; `listenbrainz-listens'.
39 ;; Some API calls require a user token, which can be found in your ListenBrainz
40 ;; profile. Configure, set or `customize' the `listenbrainz-api-token' as needed.
42 ;; https://listenbrainz.readthedocs.io/
57 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
58 "URL for listenbrainz API.
59 Documentation available at https://listenbrainz.readthedocs.io/"
63 (defcustom listenbrainz-api-token ""
64 "An auth token is required for some functions.
65 Details can be found near https://listenbrainz.org/profile/"
72 ;; Constants that are relevant to using the API
73 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
77 (defconst listenbrainz--MAX_LISTEN_SIZE 10240
78 "Maximum overall listen size in bytes, to prevent egregious spamming.
79 listenbrainz.webserver.views.api_tools.MAX_LISTEN_SIZE = 10240")
81 (defconst listenbrainz--MAX_ITEMS_PER_GET 100
82 "The maximum number of listens returned in a single GET request.
83 listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET = 100")
85 (defconst listenbrainz--DEFAULT_ITEMS_PER_GET 25
86 "The default number of listens returned in a single GET request.
87 listenbrainz.webserver.views.api_tools.DEFAULT_ITEMS_PER_GET = 25")
89 (defconst listenbrainz--MAX_TAGS_PER_LISTEN 50
90 "The maximum number of tags per listen.
91 listenbrainz.webserver.views.api_tools.MAX_TAGS_PER_LISTEN = 50")
93 (defconst listenbrainz--MAX_TAG_SIZE 64
94 "The maximum length of a tag.
95 listenbrainz.webserver.views.api_tools.MAX_TAG_SIZE = 64")
103 (defun listenbrainz-timestamp (&optional time)
104 "Return a ListenBrainz compatible timestamp for the `current-time' or TIME.
105 All timestamps used in ListenBrainz are UNIX epoch timestamps in UTC."
106 (if time (time-convert time 'integer)
107 (time-convert (current-time) 'integer)))
110 ;;; ;; ;; ; ; ; ; ; ;
112 ;; Formatting & formatters
116 (defmacro listenbrainz--deformatter (name format-string format-args alist)
117 "Generate function with NAME to format data returned from an API call.
118 The function has the name `listenbrainz--format-NAME`.
120 The ALIST is the relevant section of the response payload in dotted form as
121 seen in `let-alist'. The FORMAT-STRING and FORMAT-ARGS are applied to each
122 element in ALIST and also assumed to be accessors for the ALIST, but can
123 be any valid `format' string.
125 For example, format track info from .payload.listens as an `org-mode' table.
127 (listenbrainz--deformatter (\"listens\"
128 \"%s | %s | %s | %s |\n\"
129 (.track_metadata.artist_name
130 .track_metadata.track_name
131 .track_metadata.release_name
135 macroexpands to something like ->
137 (defun listenbrainz--format-listens (data)
142 (format \"%s | %s | %s | %s |\n\"
143 .track_metadata.artist_name
144 .track_metadata.track_name
145 .track_metadata.release_name
150 (let ((f (intern (concat "listenbrainz--format-" name)))
151 (doc "Some details from listening data."))
152 `(defun ,f (data) ,doc
157 (format ,format-string ,@format-args)))
161 ;; Core API formatters
163 ;; listens -> listenbrainz--format-listens
164 (listenbrainz--deformatter "listens"
166 (.track_metadata.artist_name
167 .track_metadata.track_name)
170 ;; playing now -> listenbrainz--format-playing
171 (listenbrainz--deformatter "playing"
173 (.track_metadata.artist_name
174 .track_metadata.track_name)
178 ;; Statistics API formatters
180 ;; releases -> listenbrainz--format-stats-0
181 (listenbrainz--deformatter "stats-0"
183 (.artist_name .release_name .listen_count)
186 ;; artists -> listenbrainz--format-stats-1
187 (listenbrainz--deformatter "stats-1"
189 (.artist_name .listen_count)
192 ;; recordings -> listenbrainz--format-stats-2
193 (listenbrainz--deformatter "stats-2"
195 (.artist_name .track_name .listen_count)
199 ;; Social API formatters
201 ;; follows -> listenbrainz--format-followers-list
202 (listenbrainz--deformatter "followers-list"
207 ;; follows -> listenbrainz--format-followers-graph
208 (listenbrainz--deformatter "followers-graph"
210 (i (cdadr data)) ;; note scope
213 ;; following -> listenbrainz--format-following
214 (listenbrainz--deformatter "following"
220 ;;; ;; ;; ; ; ; ; ; ;
222 ;; Core API Endpoints
223 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
227 (defun listenbrainz-validate-token (token)
228 "Check if TOKEN is valid. Return a username or nil."
229 (message "listenbrainz: checking token %s" token)
231 (request-response-data
233 (format "%s/1/validate-token" listenbrainz-api-url)
235 :headers (list `("Authorization" . ,(format "Token %s" token)))
238 :success (cl-function
239 (lambda (&key data &allow-other-keys)
240 (if (eq t (assoc-default 'valid data))
241 (message "Token is valid for user: %s"
242 (assoc-default 'user_name data))
243 (message "Not a valid user token"))))))))
244 ;; return user_name or nil
245 (if (assoc-default 'user_name response)
246 (format "%s" (assoc-default 'user_name response))
251 (defun listenbrainz-listens (username &optional count)
252 "Get listing data for USERNAME (optionally get COUNT number of items)."
253 (message "listenbrainz: getting listens for %s" username)
254 (let* ((limit (if count count 25))
256 (request-response-data
258 (format "%s/1/user/%s/listens" listenbrainz-api-url username)
260 :params (list `("count" . ,limit))
263 :success (cl-function
264 (lambda (&key data &allow-other-keys)
265 (message "Listens for user: %s" username)))))))
266 (princ (listenbrainz--format-listens response))))
270 (defun listenbrainz-playing-now (username)
271 "Get `playing now' info for USERNAME."
272 (message "listenbrainz: getting playing now for %s" username)
274 (request-response-data
276 (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
280 :success (cl-function
281 (lambda (&key data &allow-other-keys)
282 (message "User playing now: %s" username)))))))
283 (princ (listenbrainz--format-playing response))))
287 ;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
288 ;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc
290 (defun listenbrainz-submit-listen (type artist track &optional release)
291 "Submit listening data to ListenBrainz.
292 - listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
293 - ARTIST name (string)
294 - TRACK title (string)
295 - RELEASE title (string) also album title."
296 (message "listenbrainz: submitting %s - %s - %s" artist track release)
297 (let* ((json-null "")
298 (now (listenbrainz-timestamp))
299 (token (format "Token %s" listenbrainz-api-token))
302 (cons "listen_type" type)
306 (when (string= type "single") (cons "listened_at" now))
307 (list "track_metadata"
308 (cons "artist_name" artist)
309 (cons "track_name" track)
310 ;; (cons "release_name" release)
313 (format "%s/1/submit-listens" listenbrainz-api-url)
316 :headers (list '("Content-Type" . "application/json")
317 `("Authorization" . ,token))
319 :success (cl-function
320 (lambda (&key data &allow-other-keys)
321 (message "status: %s" (assoc-default 'status data)))))))
324 (defun listenbrainz-submit-single-listen (artist track &optional release)
325 "Submit data for a single track (ARTIST TRACK and optional RELEASE)."
326 (listenbrainz-submit-listen "single" artist track (when release release)))
329 (defun listenbrainz-submit-playing-now (artist track &optional release)
330 "Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
331 (listenbrainz-submit-listen "playing_now" artist track (when release release)))
334 ;;; ;; ;; ; ; ; ; ; ;
336 ;; Statistics API Endpoints
337 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
343 (defun listenbrainz-stats-recordings (username &optional count range)
344 "Get top tracks for USERNAME (optionally get COUNT number of items.
345 RANGE (str) – Optional, time interval for which statistics should be collected,
346 possible values are week, month, year, all_time, defaults to all_time."
347 (message "listenbrainz: getting top releases for %s" username)
348 (let* ((limit (if count count 25))
349 (range (if range range "all_time"))
351 (request-response-data
353 (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
355 :params (list `("count" . ,limit)
359 :success (cl-function
360 (lambda (&key data &allow-other-keys)
361 (message "Top recordings for user: %s" username)))))))
362 (princ (listenbrainz--format-stats-2 response))))
366 (defun listenbrainz-stats-releases (username &optional count range)
367 "Get top releases for USERNAME (optionally get COUNT number of items.
368 RANGE (str) – Optional, time interval for which statistics should be collected,
369 possible values are week, month, year, all_time, defaults to all_time."
370 (message "listenbrainz: getting top releases for %s" username)
371 (let* ((limit (if count count 25))
372 (range (if range range "all_time"))
374 (request-response-data
376 (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
378 :params (list `("count" . ,limit)
382 :success (cl-function
383 (lambda (&key data &allow-other-keys)
384 (message "Top releases for user: %s" username)))))))
385 (princ (listenbrainz--format-stats-0 response))))
389 (defun listenbrainz-stats-artists (username &optional count range)
390 "Get top artists for USERNAME (optionally get COUNT number of items.
391 RANGE (str) – Optional, time interval for which statistics should be collected,
392 possible values are week, month, year, all_time, defaults to all_time."
393 (message "listenbrainz: getting top artists for %s" username)
394 (let* ((limit (if count count 25))
395 (range (if range range "all_time"))
397 (request-response-data
399 (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
401 :params (list `("count" . ,limit)
405 :success (cl-function
406 (lambda (&key data &allow-other-keys)
407 (message "Top artists for user: %s" username)))))))
408 (princ (listenbrainz--format-stats-1 response))))
411 ;;; ;; ;; ; ; ; ; ; ;
413 ;; Social API Endpoints
414 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
420 (defun listenbrainz-followers (username &optional output)
421 "Fetch the list of followers of USERNAME.
422 OUTPUT format can be either `list' (default) or `graph'."
423 (message "listenbrainz: getting followers for %s" username)
425 (request-response-data
427 (format "%s/1/user/%s/followers" listenbrainz-api-url username)
431 :success (cl-function
432 (lambda (&key data &allow-other-keys)
433 (message "Followers for %s" username)))))))
434 (if (string= "graph" output)
435 (princ (listenbrainz--format-followers-graph response))
436 (princ (listenbrainz--format-followers-list response)))))
439 (defun listenbrainz-following (username)
440 "Fetch the list of users followed by USERNAME."
441 (message "listenbrainz: getting users %s is following" username)
443 (request-response-data
445 (format "%s/1/user/%s/following" listenbrainz-api-url username)
449 :success (cl-function
450 (lambda (&key data &allow-other-keys)
451 (message "Users %s is following" username)))))))
452 (princ (listenbrainz--format-following response))))
457 (provide 'listenbrainz)
459 ;;; listenbrainz.el ends here