;;; listenbrainz.el --- ListenBrainz API interface -*- coding: utf-8; lexical-binding: t -*-

;; Copyright 2023 FoAM
;;
;; Author: nik gaffney <nik@fo.am>
;; Created: 2023-05-05
;; Package-Version: 20260603.1009
;; Package-Revision: abd37d9f1c25
;; Package-Requires: ((emacs "27.1") (request "0.3"))
;; Keywords: music, scrobbling, multimedia
;; URL: https://github.com/zzkt/metabrainz

;; This file is not part of GNU Emacs.

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.


;;; Commentary:

;; An interface to ListenBrainz, a project to store a record of the music that
;; you listen to. The listening data, can be used to provide statistics,
;; recommendations and general exploration.
;;
;; The package can be used programmatically (e.g. from a music player) to auto
;; submit listening data `listenbrainz-submit-listen'. There are other entrypoints
;; for reading user stats such as `listenbrainz-stats-artists' or
;; `listenbrainz-listens'.
;;
;; Some API calls require a user token, which can be found in your ListenBrainz
;; profile. Configure, set or `customize' the `listenbrainz-api-token' as needed.
;;
;; https://listenbrainz.readthedocs.io/


;;; Code:

(require 'request)
(require 'json)


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; API config
;;
;;; ; ;; ;;

(defcustom listenbrainz-api-url "https://api.listenbrainz.org"
  "URL for listenbrainz API.
Documentation available at https://listenbrainz.readthedocs.io/"
  :type 'string
  :group 'listenbrainz)

(defcustom listenbrainz-api-token ""
  "An auth token is required for some functions.
Details can be found near https://listenbrainz.org/profile/"
  :type 'string
  :group 'listenbrainz)


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Constants that are relevant to using the API
;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
;;
;; ;; ; ;  ;

(defconst listenbrainz--MAX_LISTEN_SIZE 10240
  "Maximum overall listen size in bytes, to prevent egregious spamming.
listenbrainz.webserver.views.api_tools.MAX_LISTEN_SIZE = 10240")

(defconst listenbrainz--MAX_ITEMS_PER_GET 100
  "The maximum number of listens returned in a single GET request.
listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET = 100")

(defconst listenbrainz--DEFAULT_ITEMS_PER_GET 25
  "The default number of listens returned in a single GET request.
listenbrainz.webserver.views.api_tools.DEFAULT_ITEMS_PER_GET = 25")

(defconst listenbrainz--MAX_TAGS_PER_LISTEN 50
  "The maximum number of tags per listen.
listenbrainz.webserver.views.api_tools.MAX_TAGS_PER_LISTEN = 50")

(defconst listenbrainz--MAX_TAG_SIZE 64
  "The maximum length of a tag.
listenbrainz.webserver.views.api_tools.MAX_TAG_SIZE = 64")

;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Timestamps
;;
;;; ;; ;  ;

(defun listenbrainz-timestamp (&optional time)
  "Return a ListenBrainz compatible timestamp for the `current-time' or TIME.
All timestamps used in ListenBrainz are UNIX epoch timestamps in UTC."
  (if time (time-convert time 'integer)
      (time-convert (current-time) 'integer)))


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Formatting & formatters
;;
;;;; ; ;; ;

(defmacro listenbrainz--deformatter (name format-string format-args alist)
  "Generate function with NAME to format data returned from an API call.
The function has the name `listenbrainz--format-NAME`.

The ALIST is the relevant section of the response payload in dotted form as
seen in `let-alist'. The FORMAT-STRING and FORMAT-ARGS are applied to each
element in ALIST and also assumed to be accessors for the ALIST, but can
be any valid `format' string.

For example, format track info from .payload.listens as an `org-mode' table.

 (listenbrainz--deformatter (\"listens\"
                           \"%s | %s | %s | %s |\n\"
                           (.track_metadata.artist_name
                            .track_metadata.track_name
                            .track_metadata.release_name
                            .recording_msid)
                           .payload.listens))

macroexpands to something like ->

 (defun listenbrainz--format-listens (data)
   (let-alist data
              (seq-map
               (lambda (i)
                 (let-alist i
                            (format \"%s | %s | %s | %s |\n\"
                                    .track_metadata.artist_name
                                    .track_metadata.track_name
                                    .track_metadata.release_name
                                    .recording_msid
                                    )))
               .payload.listens)))"

  (let ((f (intern (concat "listenbrainz--format-" name)))
        (doc "Some details from listening data."))
    `(defun ,f (data) ,doc
       (let-alist data
                  (seq-map
                   (lambda (i)
                     (let-alist i
                                (format ,format-string ,@format-args)))
                   ,alist)))))


;; Core API formatters

;; listens -> listenbrainz--format-listens
(listenbrainz--deformatter "listens"
                           "%s | %s |\n"
                           (.track_metadata.artist_name
                            .track_metadata.track_name)
                           .payload.listens)

;; playing now -> listenbrainz--format-playing
(listenbrainz--deformatter "playing"
                           "%s | %s |\n"
                           (.track_metadata.artist_name
                            .track_metadata.track_name)
                           .payload.listens)


;; Statistics API formatters

;; releases -> listenbrainz--format-stats-0
(listenbrainz--deformatter "stats-0"
                           "%s | %s | %s |\n"
                           (.artist_name .release_name .listen_count)
                           .payload.releases)

;; artists -> listenbrainz--format-stats-1
(listenbrainz--deformatter "stats-1"
                           "%s | %s |\n"
                           (.artist_name .listen_count)
                           .payload.artists)

;; recordings -> listenbrainz--format-stats-2
(listenbrainz--deformatter "stats-2"
                           "%s | %s | %s |\n"
                           (.artist_name .track_name .listen_count)
                           .payload.recordings)


;; Social API formatters

;; follows -> listenbrainz--format-followers-list
(listenbrainz--deformatter "followers-list"
                           "%s |\n"
                           (i) ;; note scope
                           .followers)

;; follows -> listenbrainz--format-followers-graph
(listenbrainz--deformatter "followers-graph"
                           "%s -> %s |\n"
                           (i (cdadr data)) ;; note scope
                           .followers)

;; following -> listenbrainz--format-following
(listenbrainz--deformatter "following"
                           "%s |\n"
                           (i) ;; note scope
                           .following)


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Core API Endpoints
;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
;;
;;; ; ;; ; ;   ;

(defun listenbrainz-validate-token (token)
  "Check if TOKEN is valid. Return a username or nil."
  (message "listenbrainz: checking token %s" token)
  (let ((response
          (request-response-data
           (request
            (format "%s/1/validate-token" listenbrainz-api-url)
            :type "GET"
            :headers (list `("Authorization" . ,(format "Token %s" token)))
            :parser 'json-read
            :sync t
            :success (cl-function
                      (lambda (&key data &allow-other-keys)
                        (if (eq t (assoc-default 'valid data))
                            (message "Token is valid for user: %s"
                                     (assoc-default 'user_name data))
                            (message "Not a valid user token"))))))))
    ;; return user_name or nil
    (if (assoc-default 'user_name response)
        (format "%s" (assoc-default 'user_name response))
        nil)))


;;;###autoload
(defun listenbrainz-listens (username &optional count)
  "Get listing data for USERNAME (optionally get COUNT number of items)."
  (message "listenbrainz: getting listens for %s" username)
  (let* ((limit (if count count 25))
         (response
           (request-response-data
            (request
             (format "%s/1/user/%s/listens" listenbrainz-api-url username)
             :type "GET"
             :params (list `("count" . ,limit))
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Listens for user: %s\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-listens response))))


;;;###autoload
(defun listenbrainz-playing-now (username)
  "Get `playing now' info for USERNAME."
  (message "listenbrainz: getting playing now for %s" username)
  (let* ((response
           (request-response-data
            (request
             (format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
             :type "GET"
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "User playing now: %s\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-playing response))))


;; see
;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc

(defun listenbrainz-submit-listen (type artist track &optional release)
  "Submit listening data to ListenBrainz.
- listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
- ARTIST name (string)
- TRACK title (string)
- RELEASE title (string) also  album title."
  (message "listenbrainz: submitting %s - %s - %s" artist track release)
  (let* ((json-null "")
         (now (listenbrainz-timestamp))
         (token (format "Token %s" listenbrainz-api-token))
         (listen (json-encode
                  (list
                   (cons "listen_type" type)
                   (list "payload"
                         (remove nil
                                 (list
                                  (when (string= type "single") (cons "listened_at" now))
                                  (list "track_metadata"
                                        (cons "artist_name" artist)
                                        (cons "track_name" track)
                                        ;; (cons "release_name" release)
                                        ))))))))
    (request
     (format "%s/1/submit-listens" listenbrainz-api-url)
     :type "POST"
     :data listen
     :headers (list '("Content-Type" . "application/json")
                    `("Authorization" . ,token))
     :parser 'json-read
     :success (cl-function
               (lambda (&key data &allow-other-keys)
                 (message "status: %s" (assoc-default 'status data)))))))

;;;###autoload
(defun listenbrainz-submit-single-listen (artist track &optional release)
  "Submit data for a single track (ARTIST TRACK and optional RELEASE)."
  (listenbrainz-submit-listen "single" artist track (when release release)))

;;;###autoload
(defun listenbrainz-submit-playing-now (artist track &optional release)
  "Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
  (listenbrainz-submit-listen "playing_now" artist track (when release release)))


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Statistics API Endpoints
;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
;;
;; ; ;; ; ;


;;;###autoload
(defun listenbrainz-stats-recordings (username &optional count range)
  "Get top tracks for USERNAME (optionally get COUNT number of items.
RANGE (str) – Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
  (message "listenbrainz: getting top releases for %s" username)
  (let* ((limit (if count count 25))
         (range (if range range "all_time"))
         (response
           (request-response-data
            (request
             (format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
             :type "GET"
             :params (list `("count" . ,limit)
                           `("range" . ,range))
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Top recordings for user: %s\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-stats-2 response))))


;;;###autoload
(defun listenbrainz-stats-releases (username &optional count range)
  "Get top releases for USERNAME (optionally get COUNT number of items.
RANGE (str) – Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
  (message "listenbrainz: getting top releases for %s" username)
  (let* ((limit (if count count 25))
         (range (if range range "all_time"))
         (response
           (request-response-data
            (request
             (format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
             :type "GET"
             :params (list `("count" . ,limit)
                           `("range" . ,range))
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Top releases for user: %s\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-stats-0 response))))


;;;###autoload
(defun listenbrainz-stats-artists (username &optional count range)
  "Get top artists for USERNAME (optionally get COUNT number of items.
RANGE (str) – Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
  (message "listenbrainz: getting top artists for %s" username)
  (let* ((limit (if count count 25))
         (range (if range range "all_time"))
         (response
           (request-response-data
            (request
             (format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
             :type "GET"
             :params (list `("count" . ,limit)
                           `("range" . ,range))
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Top artists for user: %s\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-stats-1 response))))


;;; ;; ;; ;  ; ;   ;  ;      ;
;;
;; Social API Endpoints
;;  https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
;;
;;; ; ; ;;;     ;


;;;###autoload
(defun listenbrainz-followers (username &optional output)
  "Fetch the list of followers of USERNAME.
OUTPUT format can be either `list' (default) or `graph'."
  (message "listenbrainz: getting followers for %s" username)
  (let* ((response
           (request-response-data
            (request
             (format "%s/1/user/%s/followers" listenbrainz-api-url username)
             :type "GET"
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Followers for %s\n%s" username
                                  (if data data ""))))))))
    (if (string= "graph" output)
        (princ (listenbrainz--format-followers-graph response))
        (princ (listenbrainz--format-followers-list response)))))

;;;###autoload
(defun listenbrainz-following (username)
  "Fetch the list of users followed by USERNAME."
  (message "listenbrainz: getting users %s is following" username)
  (let* ((response
           (request-response-data
            (request
             (format "%s/1/user/%s/following" listenbrainz-api-url username)
             :type "GET"
             :parser 'json-read
             :sync t
             :success (cl-function
                       (lambda (&key data &allow-other-keys)
                         (message "Users %s is following\n%s" username
                                  (if data data ""))))))))
    (princ (listenbrainz--format-following response))))


;;;

(provide 'listenbrainz)

;;; listenbrainz.el ends here
