Re: [PATCH] Add offline scrobble queueing to listenbrainz scrobbler
Yoni Rabkin <[email protected]> Thu, 21 May 2026 15:39:00 -0400
| Newsgroups | gmane.emacs.emms.user |
|---|---|
| Message-ID | <[email protected]> |
Fran, can you please have a look at this patch to your work? coopi <[email protected]> writes: >>From 3381b0af16d02814ce1e254dcf49553ad73ad2f1 Mon Sep 17 00:00:00 2001 > Message-ID: <[email protected]> > MIME-Version: 1.0 > Content-Type: text/plain > > > Queue failed listen submissions to a local file and flush them to ListenBrainz > when connectivity is restored. Failed HTTPS connections hang silently with no > callback invocation; handle this by starting a per-request connection timeout > that queues the listen and kills the hung connection if no response arrives. > > * emms-listenbrainz-scrobbler.el (emms-listenbrainz-scrobbler-queue-file): New > defcustom. > (emms-listenbrainz-scrobbler-flush-interval): New defcustom. > (emms-listenbrainz-scrobbler-max-batch-size): New defcustom. > (emms-listenbrainz-scrobbler-queue-timer): New variable. > (emms-listenbrainz-scrobbler-connection-timeout): New variable. > (emms-listenbrainz-scrobbler-flushing): New variable. > (emms-listenbrainz-scrobbler--completed): New defvar-local. > (emms-listenbrainz-scrobbler--queue-listen): New function. > (emms-listenbrainz-scrobbler--read-queue): New function. > (emms-listenbrainz-scrobbler--write-queue): New function. > (emms-listenbrainz-scrobbler--make-import-payload): New function. > (emms-listenbrainz-scrobbler--handle-submission-timeout): New function. > (emms-listenbrainz-scrobbler--handle-flush-timeout): New function. > (emms-listenbrainz-scrobbler--flush-callback): New function. > (emms-listenbrainz-scrobbler--flush-batch): New function. > (emms-listenbrainz-scrobbler-flush-queue): New function. > (emms-listenbrainz-scrobbler-queue-count): New function. > (emms-listenbrainz-scrobbler-clear-queue): New function. > (emms-listenbrainz-scrobbler-make-query): Fix `duration' only being added to > extra-data when `track-number' was set. > (emms-listenbrainz-scrobbler-make-async-submission-call): Wrap `url-retrieve' in > `condition-case' to catch synchronous network errors. Extract listen data from > the payload and pass it to the callback for potential offline queueing. Start a > connection timeout timer; on expiry, queue the listen and kill the hung > connection. > (emms-listenbrainz-scrobbler-submission-callback): Change argument list > from (STATUS &optional CBARGS) to (STATUS &optional TRACK PLAYING-NOW > LISTEN-DATA). Check `emms-listenbrainz-scrobbler--completed' to avoid > double-queueing when a timeout kills the connection before the callback fires. > Check `plist-get STATUS :error' for network failures and queue failed listens > for later submission. > (emms-listenbrainz-scrobbler-enable): Start periodic queue flush timer. Flush > any queued listens from previous sessions. > (emms-listenbrainz-scrobbler-disable): Cancel periodic flush timer. > --- > emms-listenbrainz-scrobbler.el | 375 ++++++++++++++++++++++++++++++--- > 1 file changed, 344 insertions(+), 31 deletions(-) > > diff --git a/emms-listenbrainz-scrobbler.el b/emms-listenbrainz-scrobbler.el > index 2262d4e..0d30f5f 100644 > --- a/emms-listenbrainz-scrobbler.el > +++ b/emms-listenbrainz-scrobbler.el > @@ -67,6 +67,47 @@ Note that the preferred way of authenticating is using authinfo.") > nil > "Non-nil if emms-listenbrainz-scrobbler is active.") > > +(defcustom emms-listenbrainz-scrobbler-queue-file > + (expand-file-name "listenbrainz-queue" emms-directory) > + "File for storing offline listen queue." > + :type 'file > + :group 'emms) > + > +(defcustom emms-listenbrainz-scrobbler-flush-interval > + (* 10 60) ; 10 minutes > + "Interval in seconds between automatic queue flush attempts. > +Set to nil to disable periodic flushing." > + :type '(choice (integer :tag "Seconds") > + (const :tag "Disable periodic flush" nil)) > + :group 'emms) > + > +(defcustom emms-listenbrainz-scrobbler-max-batch-size > + 100 > + "Maximum number of listens to submit in a single import request. > +ListenBrainz accepts up to 1000, but smaller batches are gentler > +on the server." > + :type 'integer > + :group 'emms) > + > +(defvar emms-listenbrainz-scrobbler-queue-timer > + nil > + "Timer for periodic queue flushing.") > + > +(defvar emms-listenbrainz-scrobbler-connection-timeout > + 10 > + "Seconds to wait for a connection before treating it as failed.") > + > +(defvar emms-listenbrainz-scrobbler-flushing > + nil > + "Non-nil if a queue flush is currently in progress. > +Prevents overlapping flush operations.") > + > +(defvar-local emms-listenbrainz-scrobbler--completed > + nil > + "Non-nil when the HTTP callback has already handled this request. > +Set to t by the callback, or 'timeout by the timeout handler. Used to > +prevent double-queueing when a timeout kills a hung connection.") > + > > ;;* User token > > @@ -110,7 +151,7 @@ Note that the preferred way of authenticating is using authinfo.") > ;; 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 track-length (push (cons "duration" track-length) extra-data)) > (when extra-data (push (cons "additional_info" extra-data) metadata)) > ;; payload > (push (cons "track_metadata" metadata) payload) > @@ -156,8 +197,12 @@ Note that the preferred way of authenticating is using authinfo.") > ;; 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." > + "Submit listen, or playing-now if PLAYING-NOW non-nil, of TRACK to ListenBrainz. > +On network failure, the listen is queued to the offline queue (unless > +PLAYING-NOW is non-nil, since playing-now notifications are ephemeral)." > (let* ((payload (emms-listenbrainz-scrobbler-make-query track playing-now)) > + ;; Extract the inner listen data (for offline queueing on failure) > + (listen-data (aref (cdr (assoc "payload" payload)) 0)) > (token (emms-listenbrainz-scrobbler-get-token)) > (token-string (encode-coding-string (concat "Token " token) 'utf-8)) > (url-request-method "POST") > @@ -167,35 +212,287 @@ Note that the preferred way of authenticating is using authinfo.") > (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))))) > + (condition-case _ > + (let ((buffer (url-retrieve > + emms-listenbrainz-scrobbler-submission-url > + #'emms-listenbrainz-scrobbler-submission-callback > + (list track playing-now listen-data)))) > + (when (buffer-live-p buffer) > + (with-current-buffer buffer > + (setq emms-listenbrainz-scrobbler--completed nil)) > + (run-at-time emms-listenbrainz-scrobbler-connection-timeout nil > + #'emms-listenbrainz-scrobbler--handle-submission-timeout > + buffer track playing-now listen-data))) > + (error > + ;; Synchronous error (e.g. DNS resolution failure on some platforms). > + ;; `url-retrieve' signaled an error before the callback was invoked. > + (unless playing-now > + (emms-listenbrainz-scrobbler--queue-listen listen-data) > + (message "Listenbrainz: connection error, queued for later: %s" > + (emms-track-get track 'info-title))))))) > + > +(defun emms-listenbrainz-scrobbler--handle-submission-timeout > + (buffer track playing-now listen-data) > + "Handle a submission connection timeout for BUFFER. > +If the callback has not yet handled this request, queue the listen and > +kill the hung connection." > + (when (and (buffer-live-p buffer) > + (not (buffer-local-value > + 'emms-listenbrainz-scrobbler--completed buffer))) > + (with-current-buffer buffer > + (setq emms-listenbrainz-scrobbler--completed 'timeout)) > + (kill-buffer buffer) > + (unless playing-now > + (emms-listenbrainz-scrobbler--queue-listen listen-data) > + (message "Listenbrainz: connection timed out, queued: %s" > + (emms-track-get track 'info-title))))) > > -(defun emms-listenbrainz-scrobbler-submission-callback (status &optional cbargs) > +(defun emms-listenbrainz-scrobbler-submission-callback > + (status &optional track playing-now listen-data) > "Callback to handle response from listenbrainz server. > +STATUS is the `url-retrieve' status plist. TRACK is the EMMS track. > +PLAYING-NOW is non-nil if this was a playing_now submission. > +LISTEN-DATA is the serialized listen object for offline queueing." > + (if (eq emms-listenbrainz-scrobbler--completed 'timeout) > + ;; Already handled by the timeout handler; buffer is being killed. > + nil > + (setq emms-listenbrainz-scrobbler--completed t) > + (if (plist-get status :error) > + ;; Network error: queue for later and bail out > + (unless playing-now > + (emms-listenbrainz-scrobbler--queue-listen listen-data) > + (message "Listenbrainz: network error, queued for later: %s" > + (emms-track-get track 'info-title))) > + (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))) > + (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)) > + (kill-buffer)) > + ((assoc 'error response) > + (unless playing-now > + (emms-listenbrainz-scrobbler--queue-listen listen-data)) > + (message "Listenbrainz error: %s while submitting %s" > + (alist-get 'error response) > + title)) > + (t > + (unless playing-now > + (emms-listenbrainz-scrobbler--queue-listen listen-data)) > + (error "Listenbrainz: unhandled error while submitting %s" title))))))) > + > + > +;;* Offline Queue > + > +;;; Storage > + > +(defun emms-listenbrainz-scrobbler--queue-listen (listen-data) > + "Append LISTEN-DATA to the offline queue file. > +LISTEN-DATA should be an alist suitable for ListenBrainz submission, > +i.e. it must have \"listened_at\" and \"track_metadata\" keys." > + (when listen-data > + (with-temp-buffer > + (prin1 listen-data (current-buffer)) > + (insert "\n") > + (condition-case err > + (append-to-file (point-min) (point-max) > + emms-listenbrainz-scrobbler-queue-file) > + (error > + (message "Listenbrainz: could not write to queue file: %s" > + (error-message-string err))))))) > + > +(defun emms-listenbrainz-scrobbler--read-queue () > + "Read all listens from the queue file. > +Return a list of listen alists. Each alist has \"listened_at\" and > +\"track_metadata\" keys, suitable for ListenBrainz import." > + (let ((file emms-listenbrainz-scrobbler-queue-file)) > + (when (file-exists-p file) > + (with-temp-buffer > + (insert-file-contents file) > + (goto-char (point-min)) > + (let (listens) > + (while (not (eobp)) > + (let ((start-pos (point)) > + (line (buffer-substring-no-properties > + (line-beginning-position) (line-end-position)))) > + (if (string-empty-p (string-trim line)) > + (forward-line) > + (condition-case err > + (progn > + (push (read line) listens) > + (forward-line)) > + (error > + (message "Listenbrainz: skipping malformed queue entry: %s" > + (error-message-string err)) > + (forward-line)))) > + ;; Safety: ensure we always advance past the current line > + (when (and (not (eobp)) > + (= (point) start-pos)) > + (forward-line)))) > + (nreverse listens)))))) > + > +(defun emms-listenbrainz-scrobbler--write-queue (listens) > + "Write LISTENS to the queue file, replacing any existing content. > +If LISTENS is nil, the queue file is deleted." > + (if (null listens) > + (when (file-exists-p emms-listenbrainz-scrobbler-queue-file) > + (delete-file emms-listenbrainz-scrobbler-queue-file)) > + (with-temp-buffer > + (dolist (listen listens) > + (prin1 listen (current-buffer)) > + (insert "\n")) > + (write-region (point-min) (point-max) > + emms-listenbrainz-scrobbler-queue-file nil 'silent)))) > > -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))))) > + > +;;; Flushing > + > +(defun emms-listenbrainz-scrobbler--make-import-payload (listens) > + "Create a ListenBrainz import payload from a list of LISTEN alists. > +Returns an alist with \"listen_type\" set to \"import\" and a > +\"payload\" vector containing the listens." > + `(("listen_type" . "import") > + ("payload" . ,(apply #'vector listens)))) > + > +(defun emms-listenbrainz-scrobbler-flush-queue () > + "Submit all queued (offline) listens to ListenBrainz. > +Uses the \"import\" listen type to submit listens in batches. This is > +safe to call at any time; it does nothing if the queue is empty or if a > +flush is already in progress." > + (interactive) > + (unless emms-listenbrainz-scrobbler-flushing > + (let ((listens (emms-listenbrainz-scrobbler--read-queue))) > + (when listens > + (setq emms-listenbrainz-scrobbler-flushing t) > + (message "Listenbrainz: flushing %d queued listens..." > + (length listens)) > + (emms-listenbrainz-scrobbler--flush-batch listens 0))))) > + > +(defun emms-listenbrainz-scrobbler--handle-flush-timeout > + (buffer remaining-listens) > + "Handle a flush connection timeout for BUFFER. > +REMAINING-LISTENS is all unsubmitted listens (including the timed-out > +batch). Write them back to the queue and reset the flushing flag." > + (when (and (buffer-live-p buffer) > + (not (buffer-local-value > + 'emms-listenbrainz-scrobbler--completed buffer))) > + (with-current-buffer buffer > + (setq emms-listenbrainz-scrobbler--completed 'timeout)) > + (kill-buffer buffer) > + (message "Listenbrainz: connection timed out during flush, %d listens remain queued." > + (length remaining-listens)) > + (emms-listenbrainz-scrobbler--write-queue remaining-listens) > + (setq emms-listenbrainz-scrobbler-flushing nil))) > + > +(defun emms-listenbrainz-scrobbler--flush-callback (status batch rest submitted-count) > + "Handle flush batch response from ListenBrainz server. > +STATUS is the `url-retrieve' status plist. BATCH is the list of listens > +that were just submitted. REST is the remaining unsubmitted listens. > +SUBMITTED-COUNT is the number of listens already submitted in this flush > +session." > + (if (eq emms-listenbrainz-scrobbler--completed 'timeout) > + ;; Already handled by the timeout handler. > + nil > + (setq emms-listenbrainz-scrobbler--completed t) > + (let ((err (plist-get status :error))) > + (if err > + ;; Network error: write everything remaining back to queue > + (progn > + (message > + "Listenbrainz: network error during flush, %d listens remain queued." > + (+ (length batch) (length rest))) > + (emms-listenbrainz-scrobbler--write-queue > + (append batch rest)) > + (setq emms-listenbrainz-scrobbler-flushing nil) > + (condition-case nil (kill-buffer) (error nil))) > + ;; Connection succeeded: check HTTP response > + (goto-char (point-min)) > + (let ((response (ignore-errors > + (re-search-forward "\n\n") > + (json-read)))) > + (condition-case nil (kill-buffer) (error nil)) > + (if (string= "ok" (alist-get 'status response)) > + ;; Batch accepted: continue with rest > + (emms-listenbrainz-scrobbler--flush-batch > + rest > + (+ submitted-count (length batch))) > + ;; API error: write everything remaining back to queue > + (message > + "Listenbrainz: API error during flush, %d listens remain queued." > + (+ (length batch) (length rest))) > + (emms-listenbrainz-scrobbler--write-queue > + (append batch rest)) > + (setq emms-listenbrainz-scrobbler-flushing nil))))))) > + > +(defun emms-listenbrainz-scrobbler--flush-batch (remaining-listens submitted-count) > + "Submit the next batch of REMAINING-LISTENS to ListenBrainz. > +SUBMITTED-COUNT is the number of listens already submitted in this flush > +session. On success, continues with the rest of the queue. On failure, > +writes remaining listens back to the queue file." > + (if (null remaining-listens) > + ;; All done > + (progn > + (emms-listenbrainz-scrobbler--write-queue nil) > + (setq emms-listenbrainz-scrobbler-flushing nil) > + (when (> submitted-count 0) > + (message "Listenbrainz: flushed %d queued listens." submitted-count))) > + ;; Submit next batch > + (let* ((batch-size (min (length remaining-listens) > + emms-listenbrainz-scrobbler-max-batch-size)) > + (batch (seq-take remaining-listens batch-size)) > + (rest (seq-drop remaining-listens batch-size)) > + (payload (emms-listenbrainz-scrobbler--make-import-payload batch)) > + (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) > + (condition-case _ > + (let ((buffer (url-retrieve > + emms-listenbrainz-scrobbler-submission-url > + #'emms-listenbrainz-scrobbler--flush-callback > + (list batch rest submitted-count)))) > + (when (buffer-live-p buffer) > + (with-current-buffer buffer > + (setq-local emms-listenbrainz-scrobbler--completed nil)) > + (run-at-time emms-listenbrainz-scrobbler-connection-timeout nil > + #'emms-listenbrainz-scrobbler--handle-flush-timeout > + buffer remaining-listens))) > + (error > + ;; Synchronous error (e.g. DNS failure) > + (message "Listenbrainz: connection error during flush, %d listens remain queued." > + (length remaining-listens)) > + (emms-listenbrainz-scrobbler--write-queue remaining-listens) > + (setq emms-listenbrainz-scrobbler-flushing nil)))))) > + > + > +;;; Interactive Commands > + > +(defun emms-listenbrainz-scrobbler-queue-count () > + "Display and return the number of listens in the offline queue." > + (interactive) > + (let ((count (length (emms-listenbrainz-scrobbler--read-queue)))) > + (message "Listenbrainz: %d listens queued for submission." count) > + count)) > + > +(defun emms-listenbrainz-scrobbler-clear-queue () > + "Clear all listens from the offline queue. > +This discards listens that have not yet been submitted. This action > +cannot be undone." > + (interactive) > + (when (yes-or-no-p "Discard all queued offline listens? ") > + (emms-listenbrainz-scrobbler--write-queue nil) > + (message "Listenbrainz: offline queue cleared."))) > > > ;;* Hooks > @@ -230,11 +527,15 @@ That is, if it has been played for 240 seconds or half the length of the track." > > > ;;* Entry points > + > (defun emms-listenbrainz-scrobbler-enable () > - "Enable the scrobbler and submit played tracks." > + "Enable the scrobbler and submit played tracks. > +Also starts a periodic timer to flush any queued offline listens, and > +performs an initial flush attempt." > (interactive) > ;; We rely on emms-playing-time so check for it! > - (unless emms-playing-time-mode (error "Listenbrainz scrobbler: please activate emms-playing-time-mode")) > + (unless emms-playing-time-mode > + (error "Listenbrainz scrobbler: please activate emms-playing-time-mode")) > ;; check we have credentials > (if (emms-listenbrainz-scrobbler-get-token) > (unless emms-listenbrainz-scrobbler-running > @@ -244,7 +545,15 @@ That is, if it has been played for 240 seconds or half the length of the track." > 'emms-listenbrainz-scrobbler-stop-hook) > (add-hook 'emms-player-finished-hook > 'emms-listenbrainz-scrobbler-stop-hook) > - (setq emms-listenbrainz-scrobbler-running t)) > + ;; Start periodic queue flushing > + (when emms-listenbrainz-scrobbler-flush-interval > + (setq emms-listenbrainz-scrobbler-queue-timer > + (run-at-time t > + emms-listenbrainz-scrobbler-flush-interval > + #'emms-listenbrainz-scrobbler-flush-queue))) > + (setq emms-listenbrainz-scrobbler-running t) > + ;; Flush any listens queued from previous sessions > + (emms-listenbrainz-scrobbler-flush-queue)) > (error "Listenbrainz scrobbler: no user token. Please supply and try again"))) > > (defun emms-listenbrainz-scrobbler-disable () > @@ -257,6 +566,10 @@ That is, if it has been played for 240 seconds or half the length of the track." > 'emms-listenbrainz-scrobbler-stop-hook) > (remove-hook 'emms-player-finished-hook > 'emms-listenbrainz-scrobbler-stop-hook) > + ;; Cancel periodic flush timer > + (when emms-listenbrainz-scrobbler-queue-timer > + (cancel-timer emms-listenbrainz-scrobbler-queue-timer) > + (setq emms-listenbrainz-scrobbler-queue-timer nil)) > (setq emms-listenbrainz-scrobbler-running nil))) -- "Cut your own wood and it will warm you twice"