1 ;;; listenbrainz.el --- ListenBrainz API interface -*- coding: utf-8; lexical-binding: t -*-
5 ;; Author: nik gaffney <nik@fo.am>, Frederik Vanrenterghem <frederik@vanrenterghem.biz>
9 ;; Package-Requires: ((emacs "27.1") (request "0.3"))
10 ;; Keywords: music, scrobbling, multimedia
11 ;; URL: https://github.com/zzkt/listenbrainz
13 ;; This file is not part of GNU Emacs.
15 ;; This program is free software; you can redistribute it and/or modify
16 ;; it under the terms of the GNU General Public License as published by
17 ;; the Free Software Foundation, either version 3 of the License, or
18 ;; (at your option) any later version.
20 ;; This program is distributed in the hope that it will be useful,
21 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
22 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 ;; GNU General Public License for more details.
25 ;; You should have received a copy of the GNU General Public License
26 ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
31 ;; An interface to ListenBrainz, a project to store a record of the music that
32 ;; you listen to. The listening data, can be used to provide statistics,
33 ;; recommendations and general exploration.
35 ;; The package can be used programmatically (e.g. from a music player) to auto
36 ;; submit listening data `listenbrainz-submit-listen'. There are other entrypoints
37 ;; for reading user stats such as `listenbrainz-stats-artists' or
38 ;; `listenbrainz-listens'.
40 ;; Some API calls require a user token, which can be found in your ListenBrainz
41 ;; profile. Configure, set or `customize' the `listenbrainz-api-token' as needed.
43 ;; https://listenbrainz.readthedocs.io/
58 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
59 "URL for listenbrainz API.
60 Documentation available at https://listenbrainz.readthedocs.io/"
64 (defcustom listenbrainz-api-token ""
65 "An auth token is required for some functions.
66 Details can be found near https://listenbrainz.org/profile/"
73 ;; Constants that are relevant to using the API
74 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
78 (defconst listenbrainz--MAX_LISTEN_SIZE 10240
79 "Maximum overall listen size in bytes, to prevent egregious spamming.
80 listenbrainz.webserver.views.api_tools.MAX_LISTEN_SIZE = 10240")
82 (defconst listenbrainz--MAX_ITEMS_PER_GET 100
83 "The maximum number of listens returned in a single GET request.
84 listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET = 100")
86 (defconst listenbrainz--DEFAULT_ITEMS_PER_GET 25
87 "The default number of listens returned in a single GET request.
88 listenbrainz.webserver.views.api_tools.DEFAULT_ITEMS_PER_GET = 25")
90 (defconst listenbrainz--MAX_TAGS_PER_LISTEN 50
91 "The maximum number of tags per listen.
92 listenbrainz.webserver.views.api_tools.MAX_TAGS_PER_LISTEN = 50")
94 (defconst listenbrainz--MAX_TAG_SIZE 64
95 "The maximum length of a tag.
96 listenbrainz.webserver.views.api_tools.MAX_TAG_SIZE = 64")
104 (defun listenbrainz-timestamp (&optional time)
105 "Return a ListenBrainz compatible timestamp for the `current-time' or TIME.
106 All timestamps used in ListenBrainz are UNIX epoch timestamps in UTC."
107 (if time (time-convert time 'integer)
108 (time-convert (current-time) 'integer)))
111 ;;; ;; ;; ; ; ; ; ; ;
113 ;; Formatting & formatters
117 (defmacro listenbrainz--deformatter (name format-string format-args alist)
118 "Generate function with NAME to format data returned from an API call.
119 The function has the name `listenbrainz--format-NAME`.
121 The ALIST is the relevant section of the response payload in dotted form as
122 seen in `let-alist'. The FORMAT-STRING and FORMAT-ARGS are applied to each
123 element in ALIST and also assumed to be accessors for the ALIST, but can
124 be any valid `format' string.
126 For example, format track info from .payload.listens as an `org-mode' table.
128 (listenbrainz--deformatter (\"listens\"
129 \"%s | %s | %s | %s |\n\"
130 (.track_metadata.artist_name
131 .track_metadata.track_name
132 .track_metadata.release_name
136 macroexpands to something like ->
138 (defun listenbrainz--format-listens (data)
143 (format \"%s | %s | %s | %s |\n\"
144 .track_metadata.artist_name
145 .track_metadata.track_name
146 .track_metadata.release_name
151 (let ((f (intern (concat "listenbrainz--format-" name)))
152 (doc "Some details from listening data."))
153 `(defun ,f (data) ,doc
158 (format ,format-string ,@format-args)))
162 ;; Core API formatters
164 ;; listens -> listenbrainz--format-listens
165 (listenbrainz--deformatter "listens"
167 (.track_metadata.artist_name
168 .track_metadata.track_name)
171 ;; playing now -> listenbrainz--format-playing
172 (listenbrainz--deformatter "playing"
174 (.track_metadata.artist_name
175 .track_metadata.track_name)
179 ;; Statistics API formatters
181 ;; releases -> listenbrainz--format-stats-0
182 (listenbrainz--deformatter "stats-0"
184 (.artist_name .release_name .listen_count)
187 ;; artists -> listenbrainz--format-stats-1
188 (listenbrainz--deformatter "stats-1"
190 (.artist_name .listen_count)
193 ;; recordings -> listenbrainz--format-stats-2
194 (listenbrainz--deformatter "stats-2"
196 (.artist_name .track_name .listen_count)
200 ;; Social API formatters
202 ;; follows -> listenbrainz--format-followers-list
203 (listenbrainz--deformatter "followers-list"
208 ;; follows -> listenbrainz--format-followers-graph
209 (listenbrainz--deformatter "followers-graph"
211 (i (cdadr data)) ;; note scope
214 ;; following -> listenbrainz--format-following
215 (listenbrainz--deformatter "following"
221 ;;; ;; ;; ; ; ; ; ; ;
223 ;; Core API Endpoints
224 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
228 (defun listenbrainz-validate-token (token)
229 "Check if TOKEN is valid. Return a username or nil."
230 (message "listenbrainz: checking token %s" token)
232 (request-response-data
234 (format "%s/1/validate-token" listenbrainz-api-url)
236 :headers (list `("Authorization" . ,(format "Token %s" token)))
239 :success (cl-function
240 (lambda (&key data &allow-other-keys)
241 (if (eq t (assoc-default 'valid data))
242 (message "Token is valid for user: %s"
243 (assoc-default 'user_name data))
244 (message "Not a valid user token"))))))))
245 ;; return user_name or nil
246 (if (assoc-default 'user_name response)
247 (format "%s" (assoc-default 'user_name response))
252 (defun listenbrainz-listens (username &optional count)
253 "Get listing data for USERNAME (optionally get COUNT number of items)."
254 (message "listenbrainz: getting listens for %s" username)
255 (let* ((limit (if count count 25))
257 (request-response-data
259 (format "%s/1/user/%s/listens" listenbrainz-api-url username)
261 :params (list `("count" . ,limit))
264 :success (cl-function
265 (lambda (&key data &allow-other-keys)
266 (message "Listens for user: %s\n%s" username
267 (if data data ""))))))))
268 (princ (listenbrainz--format-listens response))))
272 (defun listenbrainz-playing-now (username)
273 "Get `playing now' info for USERNAME."
274 (message "listenbrainz: getting playing now for %s" username)
276 (request-response-data
278 (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
282 :success (cl-function
283 (lambda (&key data &allow-other-keys)
284 (message "User playing now: %s\n%s" username
285 (if data data ""))))))))
286 (princ (listenbrainz--format-playing response))))
290 ;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
291 ;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc
293 (defun listenbrainz-submit-listen (type artist track &optional release timestamp)
294 "Submit listening data to ListenBrainz.
295 - listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
296 - ARTIST name (string)
297 - TRACK title (string)
298 - RELEASE title (string) also album title.
299 - TIMESTAMP time (time) track was listened to"
300 (message "listenbrainz: submitting %s - %s - %s" artist track release)
301 (let* ((json-null "")
302 (now (listenbrainz-timestamp))
303 (token (format "Token %s" listenbrainz-api-token))
306 (cons "listen_type" type)
310 (when (string= type "single") (cons "listened_at" now))
311 (when (string= type "import") (cons "listened_at" (listenbrainz-timestamp timestamp)))
312 (list "track_metadata"
313 (cons "artist_name" artist)
314 (cons "track_name" track)
315 ;; (cons "release_name" release)
318 (format "%s/1/submit-listens" listenbrainz-api-url)
321 :headers (list '("Content-Type" . "application/json")
322 `("Authorization" . ,token))
324 :success (cl-function
325 (lambda (&key data &allow-other-keys)
326 (message "status: %s" (assoc-default 'status data)))))))
329 (defun listenbrainz-submit-single-listen (artist track &optional release)
330 "Submit data for a single track (ARTIST TRACK and optional RELEASE)."
331 (listenbrainz-submit-listen "single" artist track (when release release)))
334 (defun listenbrainz-submit-playing-now (artist track &optional release)
335 "Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
336 (listenbrainz-submit-listen "playing_now" artist track (when release release)))
339 (defun listenbrainz-submit-historic-listen (artist track timestamp &optional release)
340 "Submit data for track (ARTIST TRACK and optional RELEASE) heard at TIMESTAMP."
341 (listenbrainz-submit-listen "import" artist track (if release release "") timestamp))
343 ;;; ;; ;; ; ; ; ; ; ;
345 ;; Statistics API Endpoints
346 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
352 (defun listenbrainz-stats-recordings (username &optional count range)
353 "Get top tracks for USERNAME (optionally get COUNT number of items.
354 RANGE (str) – Optional, time interval for which statistics should be collected,
355 possible values are week, month, year, all_time, defaults to all_time."
356 (message "listenbrainz: getting top releases for %s" username)
357 (let* ((limit (if count count 25))
358 (range (if range range "all_time"))
360 (request-response-data
362 (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
364 :params (list `("count" . ,limit)
368 :success (cl-function
369 (lambda (&key data &allow-other-keys)
370 (message "Top recordings for user: %s\n%s" username
371 (if data data ""))))))))
372 (princ (listenbrainz--format-stats-2 response))))
376 (defun listenbrainz-stats-releases (username &optional count range)
377 "Get top releases for USERNAME (optionally get COUNT number of items.
378 RANGE (str) – Optional, time interval for which statistics should be collected,
379 possible values are week, month, year, all_time, defaults to all_time."
380 (message "listenbrainz: getting top releases for %s" username)
381 (let* ((limit (if count count 25))
382 (range (if range range "all_time"))
384 (request-response-data
386 (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
388 :params (list `("count" . ,limit)
392 :success (cl-function
393 (lambda (&key data &allow-other-keys)
394 (message "Top releases for user: %s\n%s" username
395 (if data data ""))))))))
396 (princ (listenbrainz--format-stats-0 response))))
400 (defun listenbrainz-stats-artists (username &optional count range)
401 "Get top artists for USERNAME (optionally get COUNT number of items.
402 RANGE (str) – Optional, time interval for which statistics should be collected,
403 possible values are week, month, year, all_time, defaults to all_time."
404 (message "listenbrainz: getting top artists for %s" username)
405 (let* ((limit (if count count 25))
406 (range (if range range "all_time"))
408 (request-response-data
410 (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
412 :params (list `("count" . ,limit)
416 :success (cl-function
417 (lambda (&key data &allow-other-keys)
418 (message "Top artists for user: %s\n%s" username
419 (if data data ""))))))))
420 (princ (listenbrainz--format-stats-1 response))))
423 ;;; ;; ;; ; ; ; ; ; ;
425 ;; Social API Endpoints
426 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
432 (defun listenbrainz-followers (username &optional output)
433 "Fetch the list of followers of USERNAME.
434 OUTPUT format can be either `list' (default) or `graph'."
435 (message "listenbrainz: getting followers for %s" username)
437 (request-response-data
439 (format "%s/1/user/%s/followers" listenbrainz-api-url username)
443 :success (cl-function
444 (lambda (&key data &allow-other-keys)
445 (message "Followers for %s\n%s" username
446 (if data data ""))))))))
447 (if (string= "graph" output)
448 (princ (listenbrainz--format-followers-graph response))
449 (princ (listenbrainz--format-followers-list response)))))
452 (defun listenbrainz-following (username)
453 "Fetch the list of users followed by USERNAME."
454 (message "listenbrainz: getting users %s is following" username)
456 (request-response-data
458 (format "%s/1/user/%s/following" listenbrainz-api-url username)
462 :success (cl-function
463 (lambda (&key data &allow-other-keys)
464 (message "Users %s is following\n%s" username
465 (if data data ""))))))))
466 (princ (listenbrainz--format-following response))))
471 (provide 'listenbrainz)
473 ;;; listenbrainz.el ends here