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\n%s" username
266 (if data data ""))))))))
267 (princ (listenbrainz--format-listens response))))
271 (defun listenbrainz-playing-now (username)
272 "Get `playing now' info for USERNAME."
273 (message "listenbrainz: getting playing now for %s" username)
275 (request-response-data
277 (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
281 :success (cl-function
282 (lambda (&key data &allow-other-keys)
283 (message "User playing now: %s\n%s" username
284 (if data data ""))))))))
285 (princ (listenbrainz--format-playing response))))
289 ;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
290 ;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc
292 (defun listenbrainz-submit-listen (type artist track &optional release)
293 "Submit listening data to ListenBrainz.
294 - listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
295 - ARTIST name (string)
296 - TRACK title (string)
297 - RELEASE title (string) also album title."
298 (message "listenbrainz: submitting %s - %s - %s" artist track release)
299 (let* ((json-null "")
300 (now (listenbrainz-timestamp))
301 (token (format "Token %s" listenbrainz-api-token))
304 (cons "listen_type" type)
308 (when (string= type "single") (cons "listened_at" now))
309 (list "track_metadata"
310 (cons "artist_name" artist)
311 (cons "track_name" track)
312 ;; (cons "release_name" release)
315 (format "%s/1/submit-listens" listenbrainz-api-url)
318 :headers (list '("Content-Type" . "application/json")
319 `("Authorization" . ,token))
321 :success (cl-function
322 (lambda (&key data &allow-other-keys)
323 (message "status: %s" (assoc-default 'status data)))))))
326 (defun listenbrainz-submit-single-listen (artist track &optional release)
327 "Submit data for a single track (ARTIST TRACK and optional RELEASE)."
328 (listenbrainz-submit-listen "single" artist track (when release release)))
331 (defun listenbrainz-submit-playing-now (artist track &optional release)
332 "Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
333 (listenbrainz-submit-listen "playing_now" artist track (when release release)))
336 ;;; ;; ;; ; ; ; ; ; ;
338 ;; Statistics API Endpoints
339 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
345 (defun listenbrainz-stats-recordings (username &optional count range)
346 "Get top tracks for USERNAME (optionally get COUNT number of items.
347 RANGE (str) – Optional, time interval for which statistics should be collected,
348 possible values are week, month, year, all_time, defaults to all_time."
349 (message "listenbrainz: getting top releases for %s" username)
350 (let* ((limit (if count count 25))
351 (range (if range range "all_time"))
353 (request-response-data
355 (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
357 :params (list `("count" . ,limit)
361 :success (cl-function
362 (lambda (&key data &allow-other-keys)
363 (message "Top recordings for user: %s\n%s" username
364 (if data data ""))))))))
365 (princ (listenbrainz--format-stats-2 response))))
369 (defun listenbrainz-stats-releases (username &optional count range)
370 "Get top releases for USERNAME (optionally get COUNT number of items.
371 RANGE (str) – Optional, time interval for which statistics should be collected,
372 possible values are week, month, year, all_time, defaults to all_time."
373 (message "listenbrainz: getting top releases for %s" username)
374 (let* ((limit (if count count 25))
375 (range (if range range "all_time"))
377 (request-response-data
379 (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
381 :params (list `("count" . ,limit)
385 :success (cl-function
386 (lambda (&key data &allow-other-keys)
387 (message "Top releases for user: %s\n%s" username
388 (if data data ""))))))))
389 (princ (listenbrainz--format-stats-0 response))))
393 (defun listenbrainz-stats-artists (username &optional count range)
394 "Get top artists for USERNAME (optionally get COUNT number of items.
395 RANGE (str) – Optional, time interval for which statistics should be collected,
396 possible values are week, month, year, all_time, defaults to all_time."
397 (message "listenbrainz: getting top artists for %s" username)
398 (let* ((limit (if count count 25))
399 (range (if range range "all_time"))
401 (request-response-data
403 (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
405 :params (list `("count" . ,limit)
409 :success (cl-function
410 (lambda (&key data &allow-other-keys)
411 (message "Top artists for user: %s\n%s" username
412 (if data data ""))))))))
413 (princ (listenbrainz--format-stats-1 response))))
416 ;;; ;; ;; ; ; ; ; ; ;
418 ;; Social API Endpoints
419 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
425 (defun listenbrainz-followers (username &optional output)
426 "Fetch the list of followers of USERNAME.
427 OUTPUT format can be either `list' (default) or `graph'."
428 (message "listenbrainz: getting followers for %s" username)
430 (request-response-data
432 (format "%s/1/user/%s/followers" listenbrainz-api-url username)
436 :success (cl-function
437 (lambda (&key data &allow-other-keys)
438 (message "Followers for %s\n%s" username
439 (if data data ""))))))))
440 (if (string= "graph" output)
441 (princ (listenbrainz--format-followers-graph response))
442 (princ (listenbrainz--format-followers-list response)))))
445 (defun listenbrainz-following (username)
446 "Fetch the list of users followed by USERNAME."
447 (message "listenbrainz: getting users %s is following" username)
449 (request-response-data
451 (format "%s/1/user/%s/following" listenbrainz-api-url username)
455 :success (cl-function
456 (lambda (&key data &allow-other-keys)
457 (message "Users %s is following\n%s" username
458 (if data data ""))))))))
459 (princ (listenbrainz--format-following response))))
464 (provide 'listenbrainz)
466 ;;; listenbrainz.el ends here