]> git.vanrenterghem.biz Git - musicbrainz.git/blob - listenbrainz.el
Support individual historic listens submittion.
[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>, Frederik Vanrenterghem <frederik@vanrenterghem.biz>
6 ;; Created: 2023-05-05
7 ;; Updated: 2025-01-25
8 ;; Version: 0.2fv
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/>.
29 ;;; Commentary:
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.
34 ;;
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'.
39 ;;
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.
42 ;;
43 ;; https://listenbrainz.readthedocs.io/
46 ;;; Code:
48 (require 'request)
49 (require 'json)
52 ;;; ;; ;; ;  ; ;   ;  ;      ;
53 ;;
54 ;; API config
55 ;;
56 ;;; ; ;; ;;
58 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
59   "URL for listenbrainz API.
60 Documentation available at https://listenbrainz.readthedocs.io/"
61   :type 'string
62   :group 'listenbrainz)
64 (defcustom listenbrainz-api-token ""
65   "An auth token is required for some functions.
66 Details can be found near https://listenbrainz.org/profile/"
67   :type 'string
68   :group 'listenbrainz)
71 ;;; ;; ;; ;  ; ;   ;  ;      ;
72 ;;
73 ;; Constants that are relevant to using the API
74 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
75 ;;
76 ;; ;; ; ;  ;
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")
98 ;;; ;; ;; ;  ; ;   ;  ;      ;
99 ;;
100 ;; Timestamps
101 ;;
102 ;;; ;; ;  ;
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
112 ;;
113 ;; Formatting & formatters
114 ;;
115 ;;;; ; ;; ;
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
133                             .recording_msid)
134                            .payload.listens))
136 macroexpands to something like ->
138  (defun listenbrainz--format-listens (data)
139    (let-alist data
140               (seq-map
141                (lambda (i)
142                  (let-alist i
143                             (format \"%s | %s | %s | %s |\n\"
144                                     .track_metadata.artist_name
145                                     .track_metadata.track_name
146                                     .track_metadata.release_name
147                                     .recording_msid
148                                     )))
149                .payload.listens)))"
151   (let ((f (intern (concat "listenbrainz--format-" name)))
152         (doc "Some details from listening data."))
153     `(defun ,f (data) ,doc
154        (let-alist data
155                   (seq-map
156                    (lambda (i)
157                      (let-alist i
158                                 (format ,format-string ,@format-args)))
159                    ,alist)))))
162 ;; Core API formatters
164 ;; listens -> listenbrainz--format-listens
165 (listenbrainz--deformatter "listens"
166                            "%s | %s |\n"
167                            (.track_metadata.artist_name
168                             .track_metadata.track_name)
169                            .payload.listens)
171 ;; playing now -> listenbrainz--format-playing
172 (listenbrainz--deformatter "playing"
173                            "%s | %s |\n"
174                            (.track_metadata.artist_name
175                             .track_metadata.track_name)
176                            .payload.listens)
179 ;; Statistics API formatters
181 ;; releases -> listenbrainz--format-stats-0
182 (listenbrainz--deformatter "stats-0"
183                            "%s | %s | %s |\n"
184                            (.artist_name .release_name .listen_count)
185                            .payload.releases)
187 ;; artists -> listenbrainz--format-stats-1
188 (listenbrainz--deformatter "stats-1"
189                            "%s | %s |\n"
190                            (.artist_name .listen_count)
191                            .payload.artists)
193 ;; recordings -> listenbrainz--format-stats-2
194 (listenbrainz--deformatter "stats-2"
195                            "%s | %s | %s |\n"
196                            (.artist_name .track_name .listen_count)
197                            .payload.recordings)
200 ;; Social API formatters
202 ;; follows -> listenbrainz--format-followers-list
203 (listenbrainz--deformatter "followers-list"
204                            "%s |\n"
205                            (i) ;; note scope
206                            .followers)
208 ;; follows -> listenbrainz--format-followers-graph
209 (listenbrainz--deformatter "followers-graph"
210                            "%s -> %s |\n"
211                            (i (cdadr data)) ;; note scope
212                            .followers)
214 ;; following -> listenbrainz--format-following
215 (listenbrainz--deformatter "following"
216                            "%s |\n"
217                            (i) ;; note scope
218                            .following)
221 ;;; ;; ;; ;  ; ;   ;  ;      ;
222 ;;
223 ;; Core API Endpoints
224 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
225 ;;
226 ;;; ; ;; ; ;   ;
228 (defun listenbrainz-validate-token (token)
229   "Check if TOKEN is valid. Return a username or nil."
230   (message "listenbrainz: checking token %s" token)
231   (let ((response
232           (request-response-data
233            (request
234             (format "%s/1/validate-token" listenbrainz-api-url)
235             :type "GET"
236             :headers (list `("Authorization" . ,(format "Token %s" token)))
237             :parser 'json-read
238             :sync t
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))
248         nil)))
251 ;;;###autoload
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))
256          (response
257            (request-response-data
258             (request
259              (format "%s/1/user/%s/listens" listenbrainz-api-url username)
260              :type "GET"
261              :params (list `("count" . ,limit))
262              :parser 'json-read
263              :sync t
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))))
271 ;;;###autoload
272 (defun listenbrainz-playing-now (username)
273   "Get `playing now' info for USERNAME."
274   (message "listenbrainz: getting playing now for %s" username)
275   (let* ((response
276            (request-response-data
277             (request
278              (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
279              :type "GET"
280              :parser 'json-read
281              :sync t
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))))
289 ;; see
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))
304          (listen (json-encode
305                   (list
306                    (cons "listen_type" type)
307                    (list "payload"
308                          (remove nil
309                                  (list
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)
316                                         ))))))))
317     (request
318      (format "%s/1/submit-listens" listenbrainz-api-url)
319      :type "POST"
320      :data listen
321      :headers (list '("Content-Type" . "application/json")
322                     `("Authorization" . ,token))
323      :parser 'json-read
324      :success (cl-function
325                (lambda (&key data &allow-other-keys)
326                  (message "status: %s" (assoc-default 'status data)))))))
328 ;;;###autoload
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)))
333 ;;;###autoload
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)))
338 ;;;###autoload
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
344 ;;
345 ;; Statistics API Endpoints
346 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
347 ;;
348 ;; ; ;; ; ;
351 ;;;###autoload
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"))
359          (response
360            (request-response-data
361             (request
362              (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
363              :type "GET"
364              :params (list `("count" . ,limit)
365                            `("range" . ,range))
366              :parser 'json-read
367              :sync t
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))))
375 ;;;###autoload
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"))
383          (response
384            (request-response-data
385             (request
386              (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
387              :type "GET"
388              :params (list `("count" . ,limit)
389                            `("range" . ,range))
390              :parser 'json-read
391              :sync t
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))))
399 ;;;###autoload
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"))
407          (response
408            (request-response-data
409             (request
410              (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
411              :type "GET"
412              :params (list `("count" . ,limit)
413                            `("range" . ,range))
414              :parser 'json-read
415              :sync t
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
424 ;;
425 ;; Social API Endpoints
426 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
427 ;;
428 ;;; ; ; ;;;     ;
431 ;;;###autoload
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)
436   (let* ((response
437            (request-response-data
438             (request
439              (format "%s/1/user/%s/followers" listenbrainz-api-url username)
440              :type "GET"
441              :parser 'json-read
442              :sync t
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)))))
451 ;;;###autoload
452 (defun listenbrainz-following (username)
453   "Fetch the list of users followed by USERNAME."
454   (message "listenbrainz: getting users %s is following" username)
455   (let* ((response
456            (request-response-data
457             (request
458              (format "%s/1/user/%s/following" listenbrainz-api-url username)
459              :type "GET"
460              :parser 'json-read
461              :sync t
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))))
469 ;;;
471 (provide 'listenbrainz)
473 ;;; listenbrainz.el ends here