]> git.vanrenterghem.biz Git - musicbrainz.git/blob - listenbrainz.el
Where's the instinct?
[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" username)))))))
266     (princ (listenbrainz--format-listens response))))
269 ;;;###autoload
270 (defun listenbrainz-playing-now (username)
271   "Get `playing now' info for USERNAME."
272   (message "listenbrainz: getting playing now for %s" username)
273   (let* ((response
274            (request-response-data
275             (request
276              (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
277              :type "GET"
278              :parser 'json-read
279              :sync t
280              :success (cl-function
281                        (lambda (&key data &allow-other-keys)
282                          (message "User playing now: %s" username)))))))
283     (princ (listenbrainz--format-playing response))))
286 ;; see
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))
300          (listen (json-encode
301                   (list
302                    (cons "listen_type" type)
303                    (list "payload"
304                          (remove nil
305                                  (list
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)
311                                         ))))))))
312     (request
313      (format "%s/1/submit-listens" listenbrainz-api-url)
314      :type "POST"
315      :data listen
316      :headers (list '("Content-Type" . "application/json")
317                     `("Authorization" . ,token))
318      :parser 'json-read
319      :success (cl-function
320                (lambda (&key data &allow-other-keys)
321                  (message "status: %s" (assoc-default 'status data)))))))
323 ;;;###autoload
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)))
328 ;;;###autoload
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
335 ;;
336 ;; Statistics API Endpoints
337 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
338 ;;
339 ;; ; ;; ; ;
342 ;;;###autoload
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"))
350          (response
351            (request-response-data
352             (request
353              (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
354              :type "GET"
355              :params (list `("count" . ,limit)
356                            `("range" . ,range))
357              :parser 'json-read
358              :sync t
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))))
365 ;;;###autoload
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"))
373          (response
374            (request-response-data
375             (request
376              (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
377              :type "GET"
378              :params (list `("count" . ,limit)
379                            `("range" . ,range))
380              :parser 'json-read
381              :sync t
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))))
388 ;;;###autoload
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"))
396          (response
397            (request-response-data
398             (request
399              (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
400              :type "GET"
401              :params (list `("count" . ,limit)
402                            `("range" . ,range))
403              :parser 'json-read
404              :sync t
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
412 ;;
413 ;; Social API Endpoints
414 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
415 ;;
416 ;;; ; ; ;;;     ;
419 ;;;###autoload
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)
424   (let* ((response
425            (request-response-data
426             (request
427              (format "%s/1/user/%s/followers" listenbrainz-api-url username)
428              :type "GET"
429              :parser 'json-read
430              :sync t
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)))))
438 ;;;###autoload
439 (defun listenbrainz-following (username)
440   "Fetch the list of users followed by USERNAME."
441   (message "listenbrainz: getting users %s is following" username)
442   (let* ((response
443            (request-response-data
444             (request
445              (format "%s/1/user/%s/following" listenbrainz-api-url username)
446              :type "GET"
447              :parser 'json-read
448              :sync t
449              :success (cl-function
450                        (lambda (&key data &allow-other-keys)
451                          (message "Users %s is following" username)))))))
452     (princ (listenbrainz--format-following response))))
455 ;;;
457 (provide 'listenbrainz)
459 ;;; listenbrainz.el ends here