[PATCH] Add offline scrobble queueing to listenbrainz scrobbler
coopi <[email protected]> Thu, 21 May 2026 09:56:36 +0000
| Newsgroups | gmane.emacs.emms.user |
|---|---|
| Message-ID | <[email protected]> |
From b88cffa60e8eb8336bf8f2efee6455937f39e346 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 JSONL 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 | 380 ++++++++++++++++++++++++++++++--- 1 file changed, 349 insertions(+), 31 deletions(-) diff --git a/emms-listenbrainz-scrobbler.el b/emms-listenbrainz-scrobbler.el index 2262d4e..9ac1eb8 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.jsonl" 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,292 @@ 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 + (let ((json-str (json-encode listen-data))) + (with-temp-buffer + (insert json-str "\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) + (json-object-type 'alist) + (json-key-type 'string) + (json-array-type 'vector)) + (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 (json-read-from-string 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)) + (let ((json-object-type 'alist) + (json-key-type 'string) + (json-array-type 'vector)) + (with-temp-buffer + (dolist (listen listens) + (insert (json-encode listen) "\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 +532,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 +550,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 +571,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))) -- coopi lost: one train of thought. if found, please return.