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 ;; - listen & submit metadata to ListenBrainz
31 ;; - partial & incomplete
48 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
49 "URL for listenbrainz API.
50 Documentation available at https://listenbrainz.readthedocs.io/"
54 (defcustom listenbrainz-api-token ""
55 "An auth token is required for some functions.
56 Details can be found near https://listenbrainz.org/profile/"
63 ;; Constants that are relevant to using the API
64 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
68 (defconst listenbrainz--MAX_LISTEN_SIZE 10240
69 "Maximum overall listen size in bytes, to prevent egregious spamming.
70 listenbrainz.webserver.views.api_tools.MAX_LISTEN_SIZE = 10240")
72 (defconst listenbrainz--MAX_ITEMS_PER_GET 100
73 "The maximum number of listens returned in a single GET request.
74 listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET = 100")
76 (defconst listenbrainz--DEFAULT_ITEMS_PER_GET 25
77 "The default number of listens returned in a single GET request.
78 listenbrainz.webserver.views.api_tools.DEFAULT_ITEMS_PER_GET = 25")
80 (defconst listenbrainz--MAX_TAGS_PER_LISTEN 50
81 "The maximum number of tags per listen.
82 listenbrainz.webserver.views.api_tools.MAX_TAGS_PER_LISTEN = 50")
84 (defconst listenbrainz--MAX_TAG_SIZE 64
85 "The maximum length of a tag.
86 listenbrainz.webserver.views.api_tools.MAX_TAG_SIZE = 64")
94 (defun listenbrainz-timestamp (&optional time)
95 "Return a ListenBrainz compatible timestamp for the `current-time' or TIME.
96 All timestamps used in ListenBrainz are UNIX epoch timestamps in UTC."
97 (if time (time-convert time 'integer)
98 (time-convert (current-time) 'integer)))
101 ;;; ;; ;; ; ; ; ; ; ;
103 ;; Formatting & formatters
108 (defmacro listenbrainz--deformatter (name format-string format-args alist)
109 "Generate function with NAME to format data returned from an API call.
110 The function has the name `listenbrainz--format-NAME`.
112 The ALIST is the relevant section of the response payload in dotted form as
113 seen in `let-alist'. The FORMAT-STRING and FORMAT-ARGS are applied to each
114 element in ALIST and also assumed to be accessors for the ALIST, but can
115 be any valid `format' string.
117 For example, format track info from .payload.listens as an `org-mode' table.
119 (listenbrainz--deformatter (\"listens\"
120 \"%s | %s | %s | %s |\n\"
121 (.track_metadata.artist_name
122 .track_metadata.track_name
123 .track_metadata.release_name
127 macroexpands to something like ->
129 (defun listenbrainz--format-listens (data)
134 (format \"%s | %s | %s | %s |\n\"
135 .track_metadata.artist_name
136 .track_metadata.track_name
137 .track_metadata.release_name
142 (let ((f (intern (concat "listenbrainz--format-" name)))
143 (doc "Some details from listening data."))
144 `(defun ,f (data) ,doc
149 (format ,format-string ,@format-args)))
153 ;; Core API formatters
155 ;; listens -> listenbrainz--format-listens
156 (listenbrainz--deformatter "listens"
158 (.track_metadata.artist_name
159 .track_metadata.track_name)
162 ;; playing now -> listenbrainz--format-playing
163 (listenbrainz--deformatter "playing"
165 (.track_metadata.artist_name
166 .track_metadata.track_name)
170 ;; Statistics API formatters
172 ;; releases -> listenbrainz--format-stats-0
173 (listenbrainz--deformatter "stats-0"
175 (.artist_name .release_name .listen_count)
178 ;; artists -> listenbrainz--format-stats-1
179 (listenbrainz--deformatter "stats-1"
181 (.artist_name .listen_count)
184 ;; recordings -> listenbrainz--format-stats-2
185 (listenbrainz--deformatter "stats-2"
187 (.artist_name .track_name .listen_count)
191 ;; Social API formatters
193 ;; follows -> listenbrainz--format-followers-list
194 (listenbrainz--deformatter "followers-list"
199 ;; follows -> listenbrainz--format-followers-graph
200 (listenbrainz--deformatter "followers-graph"
202 (i (cdadr data)) ;; note scope
205 ;; following -> listenbrainz--format-following
206 (listenbrainz--deformatter "following"
212 ;;; ;; ;; ; ; ; ; ; ;
214 ;; Core API Endpoints
215 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
220 (defun listenbrainz-validate-token (token)
221 "Check if TOKEN is valid. Return a username or nil."
222 (message "listenbrainz: checking token %s" token)
224 (request-response-data
226 (format "%s/1/validate-token" listenbrainz-api-url)
228 :headers (list `("Authorization" . ,(format "Token %s" token)))
231 :success (cl-function
232 (lambda (&key data &allow-other-keys)
233 (if (eq t (assoc-default 'valid data))
234 (message "Token is valid for user: %s"
235 (assoc-default 'user_name data))
236 (message "Not a valid user token"))))))))
237 ;; return user_name or nil
238 (if (assoc-default 'user_name response)
239 (format "%s" (assoc-default 'user_name response))
244 (defun listenbrainz-listens (username &optional count)
245 "Get listing data for USERNAME (optionally get COUNT number of items)."
246 (message "listenbrainz: getting listens for %s" username)
247 (let* ((limit (if count count 25))
249 (request-response-data
251 (format "%s/1/user/%s/listens" listenbrainz-api-url username)
253 :params (list `("count" . ,limit))
256 :success (cl-function
257 (lambda (&key data &allow-other-keys)
258 (message "Listens for user: %s" username)))))))
259 (princ (listenbrainz--format-listens response))))
263 (defun listenbrainz-playing-now (username)
264 "Get `playing now' info for USERNAME."
265 (message "listenbrainz: getting playing now for %s" username)
267 (request-response-data
269 (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
273 :success (cl-function
274 (lambda (&key data &allow-other-keys)
275 (message "User playing now: %s" username)))))))
276 (princ (listenbrainz--format-playing response))))
280 ;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
281 ;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc
283 (defun listenbrainz-submit-listen (type artist track &optional release)
284 "Submit listening data to ListenBrainz.
285 - listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
286 - ARTIST name (string)
287 - TRACK title (string)
288 - RELEASE title (string) also album title."
289 (message "listenbrainz: submitting %s - %s - %s" artist track release)
290 (let* ((json-null "")
291 (now (listenbrainz-timestamp))
292 (token (format "Token %s" listenbrainz-api-token))
295 (cons "listen_type" type)
299 (when (string= type "single") (cons "listened_at" now))
300 (list "track_metadata"
301 (cons "artist_name" artist)
302 (cons "track_name" track)
303 ;; (cons "release_name" release)
306 (format "%s/1/submit-listens" listenbrainz-api-url)
309 :headers (list '("Content-Type" . "application/json")
310 `("Authorization" . ,token))
312 :success (cl-function
313 (lambda (&key data &allow-other-keys)
314 (message "status: %s" (assoc-default 'status data)))))))
317 (defun listenbrainz-submit-single-listen (artist track &optional release)
318 "Submit data for a single track (ARTIST TRACK and optional RELEASE)."
319 (listenbrainz-submit-listen "single" artist track (when release release)))
322 (defun listenbrainz-submit-playing-now (artist track &optional release)
323 "Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
324 (listenbrainz-submit-listen "playing_now" artist track (when release release)))
327 ;;; ;; ;; ; ; ; ; ; ;
329 ;; Statistics API Endpoints
330 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
336 (defun listenbrainz-stats-recordings (username &optional count range)
337 "Get top tracks for USERNAME (optionally get COUNT number of items.
338 RANGE (str) – Optional, time interval for which statistics should be collected,
339 possible values are week, month, year, all_time, defaults to all_time."
340 (message "listenbrainz: getting top releases for %s" username)
341 (let* ((limit (if count count 25))
342 (range (if range range "all_time"))
344 (request-response-data
346 (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
348 :params (list `("count" . ,limit)
352 :success (cl-function
353 (lambda (&key data &allow-other-keys)
354 (message "Top recordings for user: %s" username)))))))
355 (princ (listenbrainz--format-stats-2 response))))
359 (defun listenbrainz-stats-releases (username &optional count range)
360 "Get top releases for USERNAME (optionally get COUNT number of items.
361 RANGE (str) – Optional, time interval for which statistics should be collected,
362 possible values are week, month, year, all_time, defaults to all_time."
363 (message "listenbrainz: getting top releases for %s" username)
364 (let* ((limit (if count count 25))
365 (range (if range range "all_time"))
367 (request-response-data
369 (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
371 :params (list `("count" . ,limit)
375 :success (cl-function
376 (lambda (&key data &allow-other-keys)
377 (message "Top releases for user: %s" username)))))))
378 (princ (listenbrainz--format-stats-0 response))))
382 (defun listenbrainz-stats-artists (username &optional count range)
383 "Get top artists for USERNAME (optionally get COUNT number of items.
384 RANGE (str) – Optional, time interval for which statistics should be collected,
385 possible values are week, month, year, all_time, defaults to all_time."
386 (message "listenbrainz: getting top artists for %s" username)
387 (let* ((limit (if count count 25))
388 (range (if range range "all_time"))
390 (request-response-data
392 (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
394 :params (list `("count" . ,limit)
398 :success (cl-function
399 (lambda (&key data &allow-other-keys)
400 (message "Top artists for user: %s" username)))))))
401 (princ (listenbrainz--format-stats-1 response))))
404 ;;; ;; ;; ; ; ; ; ; ;
406 ;; Social API Endpoints
407 ;; https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
413 (defun listenbrainz-followers (username &optional output)
414 "Fetch the list of followers of USERNAME.
415 OUTPUT format can be either `list' (default) or `graph'."
416 (message "listenbrainz: getting followers for %s" username)
418 (request-response-data
420 (format "%s/1/user/%s/followers" listenbrainz-api-url username)
424 :success (cl-function
425 (lambda (&key data &allow-other-keys)
426 (message "Followers for %s" username)))))))
427 (if (string= "graph" output)
428 (princ (listenbrainz--format-followers-graph response))
429 (princ (listenbrainz--format-followers-list response)))))
432 (defun listenbrainz-following (username)
433 "Fetch the list of users followed by USERNAME."
434 (message "listenbrainz: getting users %s is following" username)
436 (request-response-data
438 (format "%s/1/user/%s/following" listenbrainz-api-url username)
442 :success (cl-function
443 (lambda (&key data &allow-other-keys)
444 (message "Users %s is following" username)))))))
445 (princ (listenbrainz--format-following response))))
450 (provide 'listenbrainz)
452 ;;; listenbrainz.el ends here