]> git.vanrenterghem.biz Git - musicbrainz.git/blob - listenbrainz.el
Waveform, variant, monolith, mask
[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 ;; - listen & submit metadata to ListenBrainz
31 ;; - partial & incomplete
32 ;; - no error checks
33 ;; - sync -> async
36 ;;; Code:
38 (require 'request)
39 (require 'json)
42 ;;; ;; ;; ;  ; ;   ;  ;      ;
43 ;;
44 ;; API config
45 ;;
46 ;;; ; ;; ;;
48 (defcustom listenbrainz-api-url "https://api.listenbrainz.org"
49   "URL for listenbrainz API.
50 Documentation available at https://listenbrainz.readthedocs.io/"
51   :type 'string
52   :group 'listenbrainz)
54 (defcustom listenbrainz-api-token ""
55   "An auth token is required for some functions.
56 Details can be found near https://listenbrainz.org/profile/"
57   :type 'string
58   :group 'listenbrainz)
61 ;;; ;; ;; ;  ; ;   ;  ;      ;
62 ;;
63 ;; Constants that are relevant to using the API
64 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
65 ;;
66 ;; ;; ; ;  ;
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")
88 ;;; ;; ;; ;  ; ;   ;  ;      ;
89 ;;
90 ;; Timestamps
91 ;;
92 ;;; ;; ;  ;
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
102 ;;
103 ;; Formatting & formatters
104 ;;
105 ;;;; ; ;; ;
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
124                             .recording_msid)
125                            .payload.listens))
127 macroexpands to something like ->
129  (defun listenbrainz--format-listens (data)
130    (let-alist data
131               (seq-map
132                (lambda (i)
133                  (let-alist i
134                             (format \"%s | %s | %s | %s |\n\"
135                                     .track_metadata.artist_name
136                                     .track_metadata.track_name
137                                     .track_metadata.release_name
138                                     .recording_msid
139                                     )))
140                .payload.listens)))"
142   (let ((f (intern (concat "listenbrainz--format-" name)))
143         (doc "Some details from listening data."))
144     `(defun ,f (data) ,doc
145        (let-alist data
146                   (seq-map
147                    (lambda (i)
148                      (let-alist i
149                                 (format ,format-string ,@format-args)))
150                    ,alist)))))
153 ;; Core API formatters
155 ;; listens -> listenbrainz--format-listens
156 (listenbrainz--deformatter "listens"
157                            "%s | %s |\n"
158                            (.track_metadata.artist_name
159                             .track_metadata.track_name)
160                            .payload.listens)
162 ;; playing now -> listenbrainz--format-playing
163 (listenbrainz--deformatter "playing"
164                            "%s | %s |\n"
165                            (.track_metadata.artist_name
166                             .track_metadata.track_name)
167                            .payload.listens)
170 ;; Statistics API formatters
172 ;; releases -> listenbrainz--format-stats-0
173 (listenbrainz--deformatter "stats-0"
174                            "%s | %s | %s |\n"
175                            (.artist_name .release_name .listen_count)
176                            .payload.releases)
178 ;; artists -> listenbrainz--format-stats-1
179 (listenbrainz--deformatter "stats-1"
180                            "%s | %s |\n"
181                            (.artist_name .listen_count)
182                            .payload.artists)
184 ;; recordings -> listenbrainz--format-stats-2
185 (listenbrainz--deformatter "stats-2"
186                            "%s | %s | %s |\n"
187                            (.artist_name .track_name .listen_count)
188                            .payload.recordings)
191 ;; Social API formatters
193 ;; follows -> listenbrainz--format-followers-list
194 (listenbrainz--deformatter "followers-list"
195                            "%s |\n"
196                            (i) ;; note scope
197                            .followers)
199 ;; follows -> listenbrainz--format-followers-graph
200 (listenbrainz--deformatter "followers-graph"
201                            "%s -> %s |\n"
202                            (i (cdadr data)) ;; note scope
203                            .followers)
205 ;; following -> listenbrainz--format-following
206 (listenbrainz--deformatter "following"
207                            "%s |\n"
208                            (i) ;; note scope
209                            .following)
212 ;;; ;; ;; ;  ; ;   ;  ;      ;
213 ;;
214 ;; Core API Endpoints
215 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
216 ;;
217 ;;; ; ;; ; ;   ;
219 ;;;###autoload
220 (defun listenbrainz-validate-token (token)
221   "Check if TOKEN is valid. Return a username or nil."
222   (message "listenbrainz: checking token %s" token)
223   (let ((response
224           (request-response-data
225            (request
226             (format "%s/1/validate-token" listenbrainz-api-url)
227             :type "GET"
228             :headers (list `("Authorization" . ,(format "Token %s" token)))
229             :parser 'json-read
230             :sync t
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))
240         nil)))
243 ;;;###autoload
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))
248          (response
249            (request-response-data
250             (request
251              (format "%s/1/user/%s/listens" listenbrainz-api-url username)
252              :type "GET"
253              :params (list `("count" . ,limit))
254              :parser 'json-read
255              :sync t
256              :success (cl-function
257                        (lambda (&key data &allow-other-keys)
258                          (message "Listens for user: %s" username)))))))
259     (princ (listenbrainz--format-listens response))))
262 ;;;###autoload
263 (defun listenbrainz-playing-now (username)
264   "Get `playing now' info for USERNAME."
265   (message "listenbrainz: getting playing now for %s" username)
266   (let* ((response
267            (request-response-data
268             (request
269              (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
270              :type "GET"
271              :parser 'json-read
272              :sync t
273              :success (cl-function
274                        (lambda (&key data &allow-other-keys)
275                          (message "User playing now: %s" username)))))))
276     (princ (listenbrainz--format-playing response))))
279 ;; see
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))
293          (listen (json-encode
294                   (list
295                    (cons "listen_type" type)
296                    (list "payload"
297                          (remove nil
298                                  (list
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)
304                                         ))))))))
305     (request
306      (format "%s/1/submit-listens" listenbrainz-api-url)
307      :type "POST"
308      :data listen
309      :headers (list '("Content-Type" . "application/json")
310                     `("Authorization" . ,token))
311      :parser 'json-read
312      :success (cl-function
313                (lambda (&key data &allow-other-keys)
314                  (message "status: %s" (assoc-default 'status data)))))))
316 ;;;###autoload
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)))
321 ;;;###autoload
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
328 ;;
329 ;; Statistics API Endpoints
330 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
331 ;;
332 ;; ; ;; ; ;
335 ;;;###autoload
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"))
343          (response
344            (request-response-data
345             (request
346              (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
347              :type "GET"
348              :params (list `("count" . ,limit)
349                            `("range" . ,range))
350              :parser 'json-read
351              :sync t
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))))
358 ;;;###autoload
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"))
366          (response
367            (request-response-data
368             (request
369              (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
370              :type "GET"
371              :params (list `("count" . ,limit)
372                            `("range" . ,range))
373              :parser 'json-read
374              :sync t
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))))
381 ;;;###autoload
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"))
389          (response
390            (request-response-data
391             (request
392              (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
393              :type "GET"
394              :params (list `("count" . ,limit)
395                            `("range" . ,range))
396              :parser 'json-read
397              :sync t
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 ;;; ;; ;; ;  ; ;   ;  ;      ;
405 ;;
406 ;; Social API Endpoints
407 ;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
408 ;;
409 ;;; ; ; ;;;     ;
412 ;;;###autoload
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)
417   (let* ((response
418            (request-response-data
419             (request
420              (format "%s/1/user/%s/followers" listenbrainz-api-url username)
421              :type "GET"
422              :parser 'json-read
423              :sync t
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)))))
431 ;;;###autoload
432 (defun listenbrainz-following (username)
433   "Fetch the list of users followed by USERNAME."
434   (message "listenbrainz: getting users %s is following" username)
435   (let* ((response
436            (request-response-data
437             (request
438              (format "%s/1/user/%s/following" listenbrainz-api-url username)
439              :type "GET"
440              :parser 'json-read
441              :sync t
442              :success (cl-function
443                        (lambda (&key data &allow-other-keys)
444                          (message "Users %s is following" username)))))))
445     (princ (listenbrainz--format-following response))))
448 ;;;
450 (provide 'listenbrainz)
452 ;;; listenbrainz.el ends here