]> git.vanrenterghem.biz Git - musicbrainz.git/blob - listenbrainz.el
Once more the fortress of pure numbers
[musicbrainz.git] / listenbrainz.el
1 ;;; listenbrainz.el --- ListenBrainz API interface -*- coding: utf-8; lexical-binding: t -*-
3 ;; Copyright 2023 FoAM
4 ;;
5 ;; Author: nik gaffney <nik@fo.am>
6 ;; Created: 2023-05-05
7 ;; Version: 0.1
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/>.
28 ;;; Commentary:
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.
33 ;;
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'.
38 ;;
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.
41 ;;
42 ;; https://listenbrainz.readthedocs.io/
45 ;;; Code:
47 (require 'request)
48 (require 'json)
51 ;;; ;; ;; ;  ; ;   ;  ;      ;
52 ;;
53 ;; API config
54 ;;
55 ;;; ; ;; ;;
57 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
58   "URL for listenbrainz API.
59 Documentation available at https://listenbrainz.readthedocs.io/"
60   :type 'string
61   :group 'listenbrainz)
63 (defcustom listenbrainz-api-token ""
64   "An auth token is required for some functions.
65 Details can be found near https://listenbrainz.org/profile/"
66   :type 'string
67   :group 'listenbrainz)
70 ;;; ;; ;; ;  ; ;   ;  ;      ;
71 ;;
72 ;; Constants that are relevant to using the API
73 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
74 ;;
75 ;; ;; ; ;  ;
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")
97 ;;; ;; ;; ;  ; ;   ;  ;      ;
98 ;;
99 ;; Timestamps
100 ;;
101 ;;; ;; ;  ;
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
111 ;;
112 ;; Formatting & formatters
113 ;;
114 ;;;; ; ;; ;
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
132                             .recording_msid)
133                            .payload.listens))
135 macroexpands to something like ->
137  (defun listenbrainz--format-listens (data)
138    (let-alist data
139               (seq-map
140                (lambda (i)
141                  (let-alist i
142                             (format \"%s | %s | %s | %s |\n\"
143                                     .track_metadata.artist_name
144                                     .track_metadata.track_name
145                                     .track_metadata.release_name
146                                     .recording_msid
147                                     )))
148                .payload.listens)))"
150   (let ((f (intern (concat "listenbrainz--format-" name)))
151         (doc "Some details from listening data."))
152     `(defun ,f (data) ,doc
153        (let-alist data
154                   (seq-map
155                    (lambda (i)
156                      (let-alist i
157                                 (format ,format-string ,@format-args)))
158                    ,alist)))))
161 ;; Core API formatters
163 ;; listens -> listenbrainz--format-listens
164 (listenbrainz--deformatter "listens"
165                            "%s | %s |\n"
166                            (.track_metadata.artist_name
167                             .track_metadata.track_name)
168                            .payload.listens)
170 ;; playing now -> listenbrainz--format-playing
171 (listenbrainz--deformatter "playing"
172                            "%s | %s |\n"
173                            (.track_metadata.artist_name
174                             .track_metadata.track_name)
175                            .payload.listens)
178 ;; Statistics API formatters
180 ;; releases -> listenbrainz--format-stats-0
181 (listenbrainz--deformatter "stats-0"
182                            "%s | %s | %s |\n"
183                            (.artist_name .release_name .listen_count)
184                            .payload.releases)
186 ;; artists -> listenbrainz--format-stats-1
187 (listenbrainz--deformatter "stats-1"
188                            "%s | %s |\n"
189                            (.artist_name .listen_count)
190                            .payload.artists)
192 ;; recordings -> listenbrainz--format-stats-2
193 (listenbrainz--deformatter "stats-2"
194                            "%s | %s | %s |\n"
195                            (.artist_name .track_name .listen_count)
196                            .payload.recordings)
199 ;; Social API formatters
201 ;; follows -> listenbrainz--format-followers-list
202 (listenbrainz--deformatter "followers-list"
203                            "%s |\n"
204                            (i) ;; note scope
205                            .followers)
207 ;; follows -> listenbrainz--format-followers-graph
208 (listenbrainz--deformatter "followers-graph"
209                            "%s -> %s |\n"
210                            (i (cdadr data)) ;; note scope
211                            .followers)
213 ;; following -> listenbrainz--format-following
214 (listenbrainz--deformatter "following"
215                            "%s |\n"
216                            (i) ;; note scope
217                            .following)
220 ;;; ;; ;; ;  ; ;   ;  ;      ;
221 ;;
222 ;; Core API Endpoints
223 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
224 ;;
225 ;;; ; ;; ; ;   ;
227 (defun listenbrainz-validate-token (token)
228   "Check if TOKEN is valid. Return a username or nil."
229   (message "listenbrainz: checking token %s" token)
230   (let ((response
231           (request-response-data
232            (request
233             (format "%s/1/validate-token" listenbrainz-api-url)
234             :type "GET"
235             :headers (list `("Authorization" . ,(format "Token %s" token)))
236             :parser 'json-read
237             :sync t
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))
247         nil)))
250 ;;;###autoload
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))
255          (response
256            (request-response-data
257             (request
258              (format "%s/1/user/%s/listens" listenbrainz-api-url username)
259              :type "GET"
260              :params (list `("count" . ,limit))
261              :parser 'json-read
262              :sync t
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))))
270 ;;;###autoload
271 (defun listenbrainz-playing-now (username)
272   "Get `playing now' info for USERNAME."
273   (message "listenbrainz: getting playing now for %s" username)
274   (let* ((response
275            (request-response-data
276             (request
277              (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
278              :type "GET"
279              :parser 'json-read
280              :sync t
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))))
288 ;; see
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))
302          (listen (json-encode
303                   (list
304                    (cons "listen_type" type)
305                    (list "payload"
306                          (remove nil
307                                  (list
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)
313                                         ))))))))
314     (request
315      (format "%s/1/submit-listens" listenbrainz-api-url)
316      :type "POST"
317      :data listen
318      :headers (list '("Content-Type" . "application/json")
319                     `("Authorization" . ,token))
320      :parser 'json-read
321      :success (cl-function
322                (lambda (&key data &allow-other-keys)
323                  (message "status: %s" (assoc-default 'status data)))))))
325 ;;;###autoload
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)))
330 ;;;###autoload
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
337 ;;
338 ;; Statistics API Endpoints
339 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
340 ;;
341 ;; ; ;; ; ;
344 ;;;###autoload
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"))
352          (response
353            (request-response-data
354             (request
355              (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
356              :type "GET"
357              :params (list `("count" . ,limit)
358                            `("range" . ,range))
359              :parser 'json-read
360              :sync t
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))))
368 ;;;###autoload
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"))
376          (response
377            (request-response-data
378             (request
379              (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
380              :type "GET"
381              :params (list `("count" . ,limit)
382                            `("range" . ,range))
383              :parser 'json-read
384              :sync t
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))))
392 ;;;###autoload
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"))
400          (response
401            (request-response-data
402             (request
403              (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
404              :type "GET"
405              :params (list `("count" . ,limit)
406                            `("range" . ,range))
407              :parser 'json-read
408              :sync t
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
417 ;;
418 ;; Social API Endpoints
419 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
420 ;;
421 ;;; ; ; ;;;     ;
424 ;;;###autoload
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)
429   (let* ((response
430            (request-response-data
431             (request
432              (format "%s/1/user/%s/followers" listenbrainz-api-url username)
433              :type "GET"
434              :parser 'json-read
435              :sync t
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)))))
444 ;;;###autoload
445 (defun listenbrainz-following (username)
446   "Fetch the list of users followed by USERNAME."
447   (message "listenbrainz: getting users %s is following" username)
448   (let* ((response
449            (request-response-data
450             (request
451              (format "%s/1/user/%s/following" listenbrainz-api-url username)
452              :type "GET"
453              :parser 'json-read
454              :sync t
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))))
462 ;;;
464 (provide 'listenbrainz)
466 ;;; listenbrainz.el ends here