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