branch: externals/emms
commit 03206d049a69669c65e657116e2e022da3954a70
Author: Fran Burstall <fran.burst...@gmail.com>
Commit: Fran Burstall <fran.burst...@gmail.com>

    * emms-listenbrainz-scrobbler.el: new file
    
    Scrobble tracks to listenbrainz.org
---
 emms-listenbrainz-scrobbler.el | 262 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 262 insertions(+)

diff --git a/emms-listenbrainz-scrobbler.el b/emms-listenbrainz-scrobbler.el
new file mode 100644
index 0000000000..8cd26b6604
--- /dev/null
+++ b/emms-listenbrainz-scrobbler.el
@@ -0,0 +1,262 @@
+;;; emms-listenbrainz-scrobbler.el --- Listenbrainz Scrobbling API  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2024  Free Software Foundation, Inc.
+
+;; Author: Fran Burstall <fran.burst...@gmail.com>
+;; Keywords: emms, listenbrainz
+
+;; EMMS 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, or (at your option)
+;; any later version.
+;;
+;; EMMS 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 EMMS; see the file COPYING.  If not, write to the Free
+;; Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+;; MA 02110-1301, USA.
+
+;;; Commentary:
+
+;; To use listenbrainz you need to add a user token to
+;; ~/.authinfo.gpg or an equivalent file understood by auth-source
+;; with a line like this:
+
+;; machine api.listenbrainz.org password 0abc1de2-245-67fa-8906b-bc123d4dbdaa
+
+;; To enable scrobbling call (emms-listenbrainz-scrobbler-enable).
+
+
+;;; Code:
+
+;;* Requires
+
+(require 'emms-playing-time)
+(require 'auth-source)
+(require 'json)
+(require 'url-vars)
+
+;;* Vars
+
+(defvar emms-listenbrainz-scrobbler-host
+  "api.listenbrainz.org"
+  "Listenbrainz server we target.")
+
+(defvar emms-listenbrainz-scrobbler-token nil
+  "Listenbrainz user token.
+
+Note that the preferred way of authenticating is using authinfo.")
+
+(defvar emms-listenbrainz-scrobbler-submission-url
+  (concat "https://"; emms-listenbrainz-scrobbler-host "/1/submit-listens")
+  "URL for submissions.")
+
+(defvar emms-listenbrainz-scrobbler-track-play-start-timestamp
+  nil
+  "Time when a track started playing.")
+
+(defvar emms-listenbrainz-scrobbler-display-submissions
+  t
+  "Whether to display a user message on every submission.")
+
+(defvar emms-listenbrainz-scrobbler-running
+  nil
+  "Non-nil if emms-listenbrainz-scrobbler is active.")
+
+
+;;* User token
+
+(defun emms-listenbrainz-scrobbler--get-auth-token ()
+  "Return user token from auth-source."
+  (plist-get (car (auth-source-search :host emms-listenbrainz-scrobbler-host
+                                     :max 1
+                                     :require '(:secret)))
+            :secret))
+
+(declare-function emms-listenbrainz-scrobbler-get-token 
"emms-listenbrainz-scrobbler")
+(let ((token))
+  (defun emms-listenbrainz-scrobbler-get-token ()
+    "Return user token."
+    (unless token
+      (setq token (emms-listenbrainz-scrobbler--get-auth-token)))
+    (cond ((functionp token) (funcall token))
+         ((characterp token) token)
+         (t emms-listenbrainz-scrobbler-token))))
+
+
+;;* Payload
+;; there are two types of query: listen and now-playing.  The latter
+;; is ephemeral and does not want the listened_at timestamp.
+
+(defun emms-listenbrainz-scrobbler-make-query (track &optional playing-now)
+  "Collect the data from TRACK.  No timestamp when PLAYING-NOW is non-nil."
+  (let ((artist (substring-no-properties (emms-track-get track 'info-artist)))
+       (title (substring-no-properties (emms-track-get track 'info-title)))
+       (album (substring-no-properties (emms-track-get track 'info-album)))
+       (track-number (emms-track-get track 'info-tracknumber))
+       ;; (musicbrainz-id "")
+       (track-length (emms-track-get track
+                                     'info-playing-time))
+       payload metadata extra-data data)
+    (unless (and title artist) (error "Track title and artist must be known"))
+    ;; assemble metadata
+    (push (cons "artist_name" artist) metadata)
+    (push (cons "track_name" title) metadata)
+    (when album (push (cons "release_name" album) metadata))
+    ;; additional data
+    (when track-number (push (cons "tracknumber" (string-to-number 
track-number))
+                            extra-data))
+    (when track-number (push (cons "duration" track-length) extra-data))
+    (when extra-data (push (cons "additional_info" extra-data) metadata))
+    ;; payload
+    (push (cons "track_metadata" metadata) payload)
+    (unless playing-now
+      (push (cons "listened_at"
+                 (or emms-listenbrainz-scrobbler-track-play-start-timestamp
+                     (time-convert nil 'integer)))
+           payload))
+    ;; the whole package
+    (push (cons "payload" (vector payload)) data)
+    (push (cons "listen_type" (if playing-now "playing_now" "single")) data)
+    data))
+
+
+;;* Asynchronous submission
+
+;; In memorium: seek how much shorter, neater and comprehensible this code is.
+
+;; (defun emms-listenbrainz-scrobbler-make-async-submission-call (track 
&optional playing-now)
+;;   "Submit listen, or playing-now if PLAYING-NOW non-nil, of TRACK to 
listenbrainz."
+;;   (let ((data (emms-listenbrainz-scrobbler-make-query track playing-now))
+;;     (token (emms-listenbrainz-scrobbler-get-token))
+;;     (title (emms-track-get track 'info-title)))
+;;     (request emms-listenbrainz-scrobbler-submission-url
+;;       :type "POST"
+;;       :parser 'json-read
+;;       :headers `(("Content-type" . "application/json; charset=utf-8")
+;;              ("Authorization" . ,(concat "Token " token)))
+;;       :data (json-encode data)
+;;       :success
+;;       (lambda (&rest _)
+;;     (when (and (not playing-now) 
emms-listenbrainz-scrobbler-display-submissions)
+;;       (message "Listenbrainz: submitted %s." title)))
+;;       :error
+;;       (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
+;;                  (message "Listenbrainz error: %S when submitting %s"
+;;                           error-thrown title))))))
+
+;; we try to use the built-in url.  This was a struggle to get right
+;; with respect to handling things like kanji.  The solution is to
+;; encode absolutely anything beyond literal strings (so output of
+;; concat, for example) that might end up in the request: not just
+;; data but headers!
+
+(defun emms-listenbrainz-scrobbler-make-async-submission-call (track &optional 
playing-now)
+  "Submit listen, or playing-now if PLAYING-NOW non-nil, of TRACK to 
listenbrainz."
+  (let* ((payload (emms-listenbrainz-scrobbler-make-query track playing-now))
+        (token (emms-listenbrainz-scrobbler-get-token))
+        (token-string (encode-coding-string (concat "Token " token) 'utf-8))
+        (url-request-method "POST")
+        (url-request-data (encode-coding-string (json-encode payload) 'utf-8))
+        (url-request-extra-headers `(("Content-type" . "application/json; 
charset=utf-8")
+                                     ("Authorization" . ,token-string))))
+    (ignore url-request-method
+           url-request-data
+           url-request-extra-headers)
+    (url-retrieve
+     emms-listenbrainz-scrobbler-submission-url
+     #'emms-listenbrainz-scrobbler-submission-callback
+     (list (cons track playing-now)))))
+
+(defun emms-listenbrainz-scrobbler-submission-callback (status &optional 
cbargs)
+  "Callback to handle response from listenbrainz server.
+
+Ignore STATUS argument and store submission data in CBARGS."
+  (ignore status)
+  (when (< (point-max) 1)
+    (error "No response from submission server"))
+  (goto-char (point-min ))
+  (let* ((response (ignore-errors
+                    (re-search-forward "\n\n")
+                    (json-read)))
+        (track (car cbargs))
+        (playing-now (cdr cbargs))
+        (title (emms-track-get track 'info-title)))
+    (cond ((string= "ok" (alist-get 'status response))
+          (when (and (not playing-now) 
emms-listenbrainz-scrobbler-display-submissions)
+            (message "Listenbrainz: submitted %s." title))
+          ;; tidy up
+          (kill-buffer))
+         ((assoc 'error response)
+          (message "Listenbrainz error: %s while submitting %s"
+                   (alist-get 'error response)
+                   title))
+         (t (error "Listenbrainz: unhandled error while submitting %s" 
title)))))
+
+
+;;* Hooks
+
+(defun emms-listenbrainz-scrobbler-start-hook ()
+  "Record track start time."
+  (setq emms-listenbrainz-scrobbler-track-play-start-timestamp
+       (time-convert nil 'integer))
+  (let* ((current-track (emms-playlist-current-selected-track))
+        (good-to-submit (eq (emms-track-type current-track) 'file)))
+    (when good-to-submit
+      (emms-listenbrainz-scrobbler-make-async-submission-call
+       current-track t))))
+
+(defun emms-listenbrainz-scrobbler-stop-hook ()
+  "Submit the track to listenbrainz if we have listened long enough.
+
+That is, if it has been played for 240 seconds or half the length of the 
track."
+  (let ((current-track (emms-playlist-current-selected-track)))
+    (let ((track-length (emms-track-get current-track 'info-playing-time)))
+      (when (and track-length
+                ;; only submit files
+                (eq (emms-track-type current-track) 'file))
+       (when (and
+              ;; track must be longer than 30 secs
+              (> track-length 30)
+              ;; track must be played for more than 240 secs or
+              ;;   half the tracks length, whichever comes first.
+              (> emms-playing-time (min 240 (/ track-length 2))))
+         (emms-listenbrainz-scrobbler-make-async-submission-call
+          current-track))))))
+
+
+;;* Entry points
+(defun emms-listenbrainz-scrobbler-enable ()
+  "Enable the scrobbler and submit played tracks."
+  (interactive)
+  ;; check we have credentials
+  (if (emms-listenbrainz-scrobbler-get-token)
+      (unless emms-listenbrainz-scrobbler-running
+       (add-hook 'emms-player-started-hook
+                 'emms-listenbrainz-scrobbler-start-hook t)
+       (add-hook 'emms-player-stopped-hook
+                 'emms-listenbrainz-scrobbler-stop-hook)
+       (add-hook 'emms-player-finished-hook
+                 'emms-listenbrainz-scrobbler-stop-hook)
+       (setq emms-listenbrainz-scrobbler-running t))
+    (error "Listenbrainz scrobbler: no user token.  Please supply and try 
again")))
+
+(defun emms-listenbrainz-scrobbler-disable ()
+  "Disable the scrobbler and don't submit played tracks."
+  (interactive)
+  (when emms-listenbrainz-scrobbler-running
+    (remove-hook 'emms-player-started-hook
+                'emms-listenbrainz-scrobbler-start-hook)
+    (remove-hook 'emms-player-stopped-hook
+                'emms-listenbrainz-scrobbler-stop-hook)
+    (remove-hook 'emms-player-finished-hook
+                'emms-listenbrainz-scrobbler-stop-hook)
+    (setq emms-listenbrainz-scrobbler-running nil)))
+
+
+(provide 'emms-listenbrainz-scrobbler)
+;;; emms-listenbrainz-scrobbler.el ends here

Reply via email to