Re: [PATCH] Add offline scrobble queueing to listenbrainz scrobbler
"Fran Burstall (Gmail)" <[email protected]> Fri, 22 May 2026 09:36:12 +0100
| Newsgroups | gmane.emacs.emms.user |
|---|---|
| Message-ID | <CADxS8igSy7APE94PftzYmEOP02RG8MounZ7boBQKE5bfHYEa8Q@mail.gmail.com> |
--0000000000008836dd065263ecab Content-Type: text/plain; charset="UTF-8" I will look at this but it may be a while due to family issues. ---Fran On Thu, 21 May 2026 at 20:39, Yoni Rabkin <[email protected]> wrote: > > 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" > --0000000000008836dd065263ecab Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable <div dir=3D"ltr"><div class=3D"gmail_default" style=3D"font-family:arial,he= lvetica,sans-serif">I will look at this but it may be a while due to family= issues.</div><div class=3D"gmail_default" style=3D"font-family:arial,helve= tica,sans-serif"><br></div><div class=3D"gmail_default" style=3D"font-famil= y:arial,helvetica,sans-serif">---Fran</div></div><br><div class=3D"gmail_qu= ote gmail_quote_container"><div dir=3D"ltr" class=3D"gmail_attr">On Thu, 21= May 2026 at 20:39, Yoni Rabkin <<a href=3D"mailto:[email protected]">yon= [email protected]</a>> wrote:<br></div><blockquote class=3D"gmail_quote" sty= le=3D"margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);paddi= ng-left:1ex"><br> Fran, can you please have a look at this patch to your work?<br> <br> <br> coopi <<a href=3D"mailto:[email protected]" target=3D"_bl= ank">[email protected]</a>> writes:<br> <br> >>From 3381b0af16d02814ce1e254dcf49553ad73ad2f1 Mon Sep 17 00:00:00 2= 001<br> > Message-ID: <<a href=3D"mailto:[email protected]" target= =3D"_blank">[email protected]</a>><br> > MIME-Version: 1.0<br> > Content-Type: text/plain<br> ><br> ><br> > Queue failed listen submissions to a local file and flush them to List= enBrainz<br> > when connectivity is restored.=C2=A0 Failed HTTPS connections hang sil= ently with no<br> > callback invocation; handle this by starting a per-request connection = timeout<br> > that queues the listen and kills the hung connection if no response ar= rives.<br> ><br> > * emms-listenbrainz-scrobbler.el (emms-listenbrainz-scrobbler-queue-fi= le): New<br> > defcustom.<br> > (emms-listenbrainz-scrobbler-flush-interval): New defcustom.<br> > (emms-listenbrainz-scrobbler-max-batch-size): New defcustom.<br> > (emms-listenbrainz-scrobbler-queue-timer): New variable.<br> > (emms-listenbrainz-scrobbler-connection-timeout): New variable.<br> > (emms-listenbrainz-scrobbler-flushing): New variable.<br> > (emms-listenbrainz-scrobbler--completed): New defvar-local.<br> > (emms-listenbrainz-scrobbler--queue-listen): New function.<br> > (emms-listenbrainz-scrobbler--read-queue): New function.<br> > (emms-listenbrainz-scrobbler--write-queue): New function.<br> > (emms-listenbrainz-scrobbler--make-import-payload): New function.<br> > (emms-listenbrainz-scrobbler--handle-submission-timeout): New function= .<br> > (emms-listenbrainz-scrobbler--handle-flush-timeout): New function.<br> > (emms-listenbrainz-scrobbler--flush-callback): New function.<br> > (emms-listenbrainz-scrobbler--flush-batch): New function.<br> > (emms-listenbrainz-scrobbler-flush-queue): New function.<br> > (emms-listenbrainz-scrobbler-queue-count): New function.<br> > (emms-listenbrainz-scrobbler-clear-queue): New function.<br> > (emms-listenbrainz-scrobbler-make-query): Fix `duration' only bein= g added to<br> > extra-data when `track-number' was set.<br> > (emms-listenbrainz-scrobbler-make-async-submission-call): Wrap `url-re= trieve' in<br> > `condition-case' to catch synchronous network errors.=C2=A0 Extrac= t listen data from<br> > the payload and pass it to the callback for potential offline queueing= .=C2=A0 Start a<br> > connection timeout timer; on expiry, queue the listen and kill the hun= g<br> > connection.<br> > (emms-listenbrainz-scrobbler-submission-callback): Change argument lis= t<br> > from (STATUS &optional CBARGS) to (STATUS &optional TRACK PLAY= ING-NOW<br> > LISTEN-DATA).=C2=A0 Check `emms-listenbrainz-scrobbler--completed'= to avoid<br> > double-queueing when a timeout kills the connection before the callbac= k fires.<br> > Check `plist-get STATUS :error' for network failures and queue fai= led listens<br> > for later submission.<br> > (emms-listenbrainz-scrobbler-enable): Start periodic queue flush timer= .=C2=A0 Flush<br> > any queued listens from previous sessions.<br> > (emms-listenbrainz-scrobbler-disable): Cancel periodic flush timer.<br= > > ---<br> >=C2=A0 emms-listenbrainz-scrobbler.el | 375 +++++++++++++++++++++++++++= +++---<br> >=C2=A0 1 file changed, 344 insertions(+), 31 deletions(-)<br> ><br> > diff --git a/emms-listenbrainz-scrobbler.el b/emms-listenbrainz-scrobb= ler.el<br> > index 2262d4e..0d30f5f 100644<br> > --- a/emms-listenbrainz-scrobbler.el<br> > +++ b/emms-listenbrainz-scrobbler.el<br> > @@ -67,6 +67,47 @@ Note that the preferred way of authenticating is us= ing authinfo.")<br> >=C2=A0 =C2=A0 nil<br> >=C2=A0 =C2=A0 "Non-nil if emms-listenbrainz-scrobbler is active.&q= uot;)<br> >=C2=A0 <br> > +(defcustom emms-listenbrainz-scrobbler-queue-file<br> > +=C2=A0 (expand-file-name "listenbrainz-queue" emms-director= y)<br> > +=C2=A0 "File for storing offline listen queue."<br> > +=C2=A0 :type 'file<br> > +=C2=A0 :group 'emms)<br> > +<br> > +(defcustom emms-listenbrainz-scrobbler-flush-interval<br> > +=C2=A0 (* 10 60)=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0; 10 minutes<br> > +=C2=A0 "Interval in seconds between automatic queue flush attemp= ts.<br> > +Set to nil to disable periodic flushing."<br> > +=C2=A0 :type '(choice (integer :tag "Seconds")<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(const = :tag "Disable periodic flush" nil))<br> > +=C2=A0 :group 'emms)<br> > +<br> > +(defcustom emms-listenbrainz-scrobbler-max-batch-size<br> > +=C2=A0 100<br> > +=C2=A0 "Maximum number of listens to submit in a single import r= equest.<br> > +ListenBrainz accepts up to 1000, but smaller batches are gentler<br> > +on the server."<br> > +=C2=A0 :type 'integer<br> > +=C2=A0 :group 'emms)<br> > +<br> > +(defvar emms-listenbrainz-scrobbler-queue-timer<br> > +=C2=A0 nil<br> > +=C2=A0 "Timer for periodic queue flushing.")<br> > +<br> > +(defvar emms-listenbrainz-scrobbler-connection-timeout<br> > +=C2=A0 10<br> > +=C2=A0 "Seconds to wait for a connection before treating it as f= ailed.")<br> > +<br> > +(defvar emms-listenbrainz-scrobbler-flushing<br> > +=C2=A0 nil<br> > +=C2=A0 "Non-nil if a queue flush is currently in progress.<br> > +Prevents overlapping flush operations.")<br> > +<br> > +(defvar-local emms-listenbrainz-scrobbler--completed<br> > +=C2=A0 nil<br> > +=C2=A0 "Non-nil when the HTTP callback has already handled this = request.<br> > +Set to t by the callback, or 'timeout by the timeout handler.=C2= =A0 Used to<br> > +prevent double-queueing when a timeout kills a hung connection."= )<br> > +<br> >=C2=A0 <br> >=C2=A0 ;;* User token<br> >=C2=A0 <br> > @@ -110,7 +151,7 @@ Note that the preferred way of authenticating is u= sing authinfo.")<br> >=C2=A0 =C2=A0 =C2=A0 ;; additional data<br> >=C2=A0 =C2=A0 =C2=A0 (when track-number (push (cons "tracknumber&q= uot; (string-to-number track-number))<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 extra-data))<br> > -=C2=A0 =C2=A0 (when track-number (push (cons "duration" tra= ck-length) extra-data))<br> > +=C2=A0 =C2=A0 (when track-length (push (cons "duration" tra= ck-length) extra-data))<br> >=C2=A0 =C2=A0 =C2=A0 (when extra-data (push (cons "additional_info= " extra-data) metadata))<br> >=C2=A0 =C2=A0 =C2=A0 ;; payload<br> >=C2=A0 =C2=A0 =C2=A0 (push (cons "track_metadata" metadata) p= ayload)<br> > @@ -156,8 +197,12 @@ Note that the preferred way of authenticating is = using authinfo.")<br> >=C2=A0 ;; data but headers!<br> >=C2=A0 <br> >=C2=A0 (defun emms-listenbrainz-scrobbler-make-async-submission-call (t= rack &optional playing-now)<br> > -=C2=A0 "Submit listen, or playing-now if PLAYING-NOW non-nil, of= TRACK to listenbrainz."<br> > +=C2=A0 "Submit listen, or playing-now if PLAYING-NOW non-nil, of= TRACK to ListenBrainz.<br> > +On network failure, the listen is queued to the offline queue (unless= <br> > +PLAYING-NOW is non-nil, since playing-now notifications are ephemeral= )."<br> >=C2=A0 =C2=A0 (let* ((payload (emms-listenbrainz-scrobbler-make-query t= rack playing-now))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0;; Extract the inner listen data (f= or offline queueing on failure)<br> > +=C2=A0 =C2=A0 =C2=A0 (listen-data (aref (cdr (assoc "payload&quo= t; payload)) 0))<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 (token (emms-listenbrainz-scrobbler-get-tok= en))<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 (token-string (encode-coding-string (concat= "Token " token) 'utf-8))<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 (url-request-method "POST")<br> > @@ -167,35 +212,287 @@ Note that the preferred way of authenticating i= s using authinfo.")<br> >=C2=A0 =C2=A0 =C2=A0 (ignore url-request-method<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0url-request-data<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0url-request-extra-headers)<br> > -=C2=A0 =C2=A0 (url-retrieve<br> > -=C2=A0 =C2=A0 =C2=A0emms-listenbrainz-scrobbler-submission-url<br> > -=C2=A0 =C2=A0 =C2=A0#'emms-listenbrainz-scrobbler-submission-call= back<br> > -=C2=A0 =C2=A0 =C2=A0(list (cons track playing-now)))))<br> > +=C2=A0 =C2=A0 (condition-case _<br> > +=C2=A0 =C2=A0 =C2=A0(let ((buffer (url-retrieve<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= emms-listenbrainz-scrobbler-submission-url<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= #'emms-listenbrainz-scrobbler-submission-callback<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= (list track playing-now listen-data))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(when (buffer-live-p buffer)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(with-current-buffer buffer<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scro= bbler--completed nil))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(run-at-time emms-listenbrainz-scro= bbler-connection-timeout nil<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 #'emms-listenbrainz-scrobbler--handle-submission-timeout<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 buffer track playing-now listen-data)))<br> > +=C2=A0 =C2=A0 =C2=A0 (error<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0;; Synchronous error (e.g. DNS resolution = failure on some platforms).<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0;; `url-retrieve' signaled an error be= fore the callback was invoked.<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(unless playing-now<br> > +=C2=A0 =C2=A0 =C2=A0 (emms-listenbrainz-scrobbler--queue-listen liste= n-data)<br> > +=C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz: connection error, q= ueued for later: %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(emms-track-ge= t track 'info-title)))))))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--handle-submission-timeout<br> > +=C2=A0 =C2=A0 (buffer track playing-now listen-data)<br> > +=C2=A0 "Handle a submission connection timeout for BUFFER.<br> > +If the callback has not yet handled this request, queue the listen an= d<br> > +kill the hung connection."<br> > +=C2=A0 (when (and (buffer-live-p buffer)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (not (buffer-local-value<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 'emms-lis= tenbrainz-scrobbler--completed buffer)))<br> > +=C2=A0 =C2=A0 (with-current-buffer buffer<br> > +=C2=A0 =C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler--completed = 9;timeout))<br> > +=C2=A0 =C2=A0 (kill-buffer buffer)<br> > +=C2=A0 =C2=A0 (unless playing-now<br> > +=C2=A0 =C2=A0 =C2=A0 (emms-listenbrainz-scrobbler--queue-listen liste= n-data)<br> > +=C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz: connection timed ou= t, queued: %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (emms-track-get track '= info-title)))))<br> >=C2=A0 <br> > -(defun emms-listenbrainz-scrobbler-submission-callback (status &o= ptional cbargs)<br> > +(defun emms-listenbrainz-scrobbler-submission-callback<br> > +=C2=A0 =C2=A0 (status &optional track playing-now listen-data)<br= > >=C2=A0 =C2=A0 "Callback to handle response from listenbrainz serve= r.<br> > +STATUS is the `url-retrieve' status plist.=C2=A0 TRACK is the EMM= S track.<br> > +PLAYING-NOW is non-nil if this was a playing_now submission.<br> > +LISTEN-DATA is the serialized listen object for offline queueing.&quo= t;<br> > +=C2=A0 (if (eq emms-listenbrainz-scrobbler--completed 'timeout)<b= r> > +=C2=A0 =C2=A0 =C2=A0 ;; Already handled by the timeout handler; buffe= r is being killed.<br> > +=C2=A0 =C2=A0 =C2=A0 nil<br> > +=C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler--completed t)<br> > +=C2=A0 =C2=A0 (if (plist-get status :error)<br> > +=C2=A0 =C2=A0 =C2=A0;; Network error: queue for later and bail out<br= > > +=C2=A0 =C2=A0 =C2=A0(unless playing-now<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler--queue-listen= listen-data)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(message "Listenbrainz: network error= , queued for later: %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (emms-track-g= et track 'info-title)))<br> > +=C2=A0 =C2=A0 =C2=A0 (when (< (point-max) 1)<br> > +=C2=A0 =C2=A0 =C2=A0(error "No response from submission server&q= uot;))<br> > +=C2=A0 =C2=A0 =C2=A0 (goto-char (point-min))<br> > +=C2=A0 =C2=A0 =C2=A0 (let* ((response (ignore-errors<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 (re-search-forward "\n\n")<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 (json-read)))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (title (emms-track-get track '= info-title)))<br> > +=C2=A0 =C2=A0 =C2=A0(cond ((string=3D "ok" (alist-get '= status response))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (when (and (not playing-now= )<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0emms-listenbrainz-scrobbler-display-submissions)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (message "Liste= nbrainz: submitted %s." title))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (kill-buffer))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0((assoc 'error response)= <br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (unless playing-now<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (emms-listenbrainz-s= crobbler--queue-listen listen-data))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz= error: %s while submitting %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0(alist-get 'error response)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0title))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(t<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (unless playing-now<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (emms-listenbrainz-s= crobbler--queue-listen listen-data))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (error "Listenbrainz: = unhandled error while submitting %s" title)))))))<br> > +<br> > +=0C<br> > +;;* Offline Queue<br> > +<br> > +;;; Storage<br> > +<br> > +(defun emms-listenbrainz-scrobbler--queue-listen (listen-data)<br> > +=C2=A0 "Append LISTEN-DATA to the offline queue file.<br> > +LISTEN-DATA should be an alist suitable for ListenBrainz submission,<= br> > +i.e. it must have \"listened_at\" and \"track_metadata= \" keys."<br> > +=C2=A0 (when listen-data<br> > +=C2=A0 =C2=A0 (with-temp-buffer<br> > +=C2=A0 =C2=A0 =C2=A0 (prin1 listen-data (current-buffer))<br> > +=C2=A0 =C2=A0 =C2=A0 (insert "\n")<br> > +=C2=A0 =C2=A0 =C2=A0 (condition-case err<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(append-to-file (point-min) (point-max)<br= > > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0emms-listenbrainz-scrobbler-queue-file)<br> > +=C2=A0 =C2=A0 =C2=A0(error<br> > +=C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz: could not write to = queue file: %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(error-message= -string err)))))))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--read-queue ()<br> > +=C2=A0 "Read all listens from the queue file.<br> > +Return a list of listen alists.=C2=A0 Each alist has \"listened_= at\" and<br> > +\"track_metadata\" keys, suitable for ListenBrainz import.&= quot;<br> > +=C2=A0 (let ((file emms-listenbrainz-scrobbler-queue-file))<br> > +=C2=A0 =C2=A0 (when (file-exists-p file)<br> > +=C2=A0 =C2=A0 =C2=A0 (with-temp-buffer<br> > +=C2=A0 =C2=A0 =C2=A0(insert-file-contents file)<br> > +=C2=A0 =C2=A0 =C2=A0(goto-char (point-min))<br> > +=C2=A0 =C2=A0 =C2=A0(let (listens)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(while (not (eobp))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(let ((start-pos (point))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(line (buffer-= substring-no-properties<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 (line-beginning-position) (line-end-position))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(if (string-empty-p (string-= trim line))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(forward-line)= <br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(condition-case err<b= r> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(progn<= br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= (push (read line) listens)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= (forward-line))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(error<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (message &quo= t;Listenbrainz: skipping malformed queue entry: %s"<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 =C2=A0(error-message-string err))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (forward-line= ))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0;; Safety: ensure we always = advance past the current line<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(when (and (not (eobp))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 (=3D (point) start-pos))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(forward-line))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(nreverse listens))))))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--write-queue (listens)<br> > +=C2=A0 "Write LISTENS to the queue file, replacing any existing = content.<br> > +If LISTENS is nil, the queue file is deleted."<br> > +=C2=A0 (if (null listens)<br> > +=C2=A0 =C2=A0 =C2=A0 (when (file-exists-p emms-listenbrainz-scrobbler= -queue-file)<br> > +=C2=A0 =C2=A0 =C2=A0(delete-file emms-listenbrainz-scrobbler-queue-fi= le))<br> > +=C2=A0 =C2=A0 (with-temp-buffer<br> > +=C2=A0 =C2=A0 =C2=A0 (dolist (listen listens)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (prin1 listen (current-buffer))<br> > +=C2=A0 =C2=A0 =C2=A0(insert "\n"))<br> > +=C2=A0 =C2=A0 =C2=A0 (write-region (point-min) (point-max)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0emms-li= stenbrainz-scrobbler-queue-file nil 'silent))))<br> >=C2=A0 <br> > -Ignore STATUS argument and store submission data in CBARGS."<br> > -=C2=A0 (ignore status)<br> > -=C2=A0 (when (< (point-max) 1)<br> > -=C2=A0 =C2=A0 (error "No response from submission server"))= <br> > -=C2=A0 (goto-char (point-min ))<br> > -=C2=A0 (let* ((response (ignore-errors<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (re-se= arch-forward "\n\n")<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (json-= read)))<br> > -=C2=A0 =C2=A0 =C2=A0 (track (car cbargs))<br> > -=C2=A0 =C2=A0 =C2=A0 (playing-now (cdr cbargs))<br> > -=C2=A0 =C2=A0 =C2=A0 (title (emms-track-get track 'info-title)))<= br> > -=C2=A0 =C2=A0 (cond ((string=3D "ok" (alist-get 'status= response))<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 (when (and (not playing-now) emms-listenb= rainz-scrobbler-display-submissions)<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz: submi= tted %s." title))<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 ;; tidy up<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 (kill-buffer))<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0((assoc 'error response)<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz error: %s whi= le submitting %s"<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(alist-= get 'error response)<br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0title))= <br> > -=C2=A0 =C2=A0 =C2=A0 =C2=A0(t (error "Listenbrainz: unhandled er= ror while submitting %s" title)))))<br> > +=0C<br> > +;;; Flushing<br> > +<br> > +(defun emms-listenbrainz-scrobbler--make-import-payload (listens)<br> > +=C2=A0 "Create a ListenBrainz import payload from a list of LIST= EN alists.<br> > +Returns an alist with \"listen_type\" set to \"import\= " and a<br> > +\"payload\" vector containing the listens."<br> > +=C2=A0 `(("listen_type" . "import")<br> > +=C2=A0 =C2=A0 ("payload" . ,(apply #'vector listens))))= <br> > +<br> > +(defun emms-listenbrainz-scrobbler-flush-queue ()<br> > +=C2=A0 "Submit all queued (offline) listens to ListenBrainz.<br> > +Uses the \"import\" listen type to submit listens in batche= s.=C2=A0 This is<br> > +safe to call at any time; it does nothing if the queue is empty or if= a<br> > +flush is already in progress."<br> > +=C2=A0 (interactive)<br> > +=C2=A0 (unless emms-listenbrainz-scrobbler-flushing<br> > +=C2=A0 =C2=A0 (let ((listens (emms-listenbrainz-scrobbler--read-queue= )))<br> > +=C2=A0 =C2=A0 =C2=A0 (when listens<br> > +=C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-flushing t)<br> > +=C2=A0 =C2=A0 =C2=A0(message "Listenbrainz: flushing %d queued l= istens..."<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (length listens))<br= > > +=C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler--flush-batch listens= 0)))))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--handle-flush-timeout<br> > +=C2=A0 =C2=A0 (buffer remaining-listens)<br> > +=C2=A0 "Handle a flush connection timeout for BUFFER.<br> > +REMAINING-LISTENS is all unsubmitted listens (including the timed-out= <br> > +batch).=C2=A0 Write them back to the queue and reset the flushing fla= g."<br> > +=C2=A0 (when (and (buffer-live-p buffer)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (not (buffer-local-value<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 'emms-lis= tenbrainz-scrobbler--completed buffer)))<br> > +=C2=A0 =C2=A0 (with-current-buffer buffer<br> > +=C2=A0 =C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler--completed = 9;timeout))<br> > +=C2=A0 =C2=A0 (kill-buffer buffer)<br> > +=C2=A0 =C2=A0 (message "Listenbrainz: connection timed out durin= g flush, %d listens remain queued."<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (length remaining-listens))<br> > +=C2=A0 =C2=A0 (emms-listenbrainz-scrobbler--write-queue remaining-lis= tens)<br> > +=C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler-flushing nil)))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--flush-callback (status batch rest= submitted-count)<br> > +=C2=A0 "Handle flush batch response from ListenBrainz server.<br= > > +STATUS is the `url-retrieve' status plist.=C2=A0 BATCH is the lis= t of listens<br> > +that were just submitted.=C2=A0 REST is the remaining unsubmitted lis= tens.<br> > +SUBMITTED-COUNT is the number of listens already submitted in this fl= ush<br> > +session."<br> > +=C2=A0 (if (eq emms-listenbrainz-scrobbler--completed 'timeout)<b= r> > +=C2=A0 =C2=A0 =C2=A0 ;; Already handled by the timeout handler.<br> > +=C2=A0 =C2=A0 =C2=A0 nil<br> > +=C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler--completed t)<br> > +=C2=A0 =C2=A0 (let ((err (plist-get status :error)))<br> > +=C2=A0 =C2=A0 =C2=A0 (if err<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0;; Network error: write everything remaini= ng back to queue<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(progn<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(message<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "Listenbrainz: network error = during flush, %d listens remain queued."<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (+ (length batch) (length rest)))<= br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler--write= -queue<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (append batch rest))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-f= lushing nil)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(condition-case nil (kill-buffer) (= error nil)))<br> > +=C2=A0 =C2=A0 =C2=A0;; Connection succeeded: check HTTP response<br> > +=C2=A0 =C2=A0 =C2=A0(goto-char (point-min))<br> > +=C2=A0 =C2=A0 =C2=A0(let ((response (ignore-errors<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0(re-search-forward "\n\n")<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0(json-read))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(condition-case nil (kill-buffer) (error n= il))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(if (string=3D "ok" (alist-get &= #39;status response))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0;; Batch accepted: continue = with rest<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler= --flush-batch<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 rest<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (+ submitted-count (length = batch)))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0;; API error: write everything rema= ining back to queue<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(message<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "Listenbrainz: API error duri= ng flush, %d listens remain queued."<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (+ (length batch) (length rest)))<= br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler--write= -queue<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 (append batch rest))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-f= lushing nil)))))))<br> > +<br> > +(defun emms-listenbrainz-scrobbler--flush-batch (remaining-listens su= bmitted-count)<br> > +=C2=A0 "Submit the next batch of REMAINING-LISTENS to ListenBrai= nz.<br> > +SUBMITTED-COUNT is the number of listens already submitted in this fl= ush<br> > +session.=C2=A0 On success, continues with the rest of the queue.=C2= =A0 On failure,<br> > +writes remaining listens back to the queue file."<br> > +=C2=A0 (if (null remaining-listens)<br> > +=C2=A0 =C2=A0 =C2=A0 ;; All done<br> > +=C2=A0 =C2=A0 =C2=A0 (progn<br> > +=C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler--write-queue nil)<br= > > +=C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-flushing nil)<b= r> > +=C2=A0 =C2=A0 =C2=A0(when (> submitted-count 0)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(message "Listenbrainz: flushed %d qu= eued listens." submitted-count)))<br> > +=C2=A0 =C2=A0 ;; Submit next batch<br> > +=C2=A0 =C2=A0 (let* ((batch-size (min (length remaining-listens)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 =C2=A0emms-listenbrainz-scrobbler-max-batch-size))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (batch (seq-take remaining-listens batch-= size))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (rest (seq-drop remaining-listens batch-s= ize))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (payload (emms-listenbrainz-scrobbler--ma= ke-import-payload batch))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (token (emms-listenbrainz-scrobbler-get-t= oken))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (token-string (encode-coding-string (conc= at "Token " token) 'utf-8))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (url-request-method "POST")<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (url-request-data (encode-coding-string (= json-encode payload) 'utf-8))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 (url-request-extra-headers<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0`(("Content-type" . "= ;application/json; charset=3Dutf-8")<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0("Authorization" .= ,token-string))))<br> > +=C2=A0 =C2=A0 =C2=A0 (ignore url-request-method<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0url-request-data<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0url-request-extra-headers)<b= r> > +=C2=A0 =C2=A0 =C2=A0 (condition-case _<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(let ((buffer (url-retrieve<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 emms-listenbrainz-scrobbler-submission-url<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 #'emms-listenbrainz-scrobbler--flush-callback<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 (list batch rest submitted-count))))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(when (buffer-live-p buffer)<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(with-current-buffer buffer<= br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(setq-local emms-list= enbrainz-scrobbler--completed nil))<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(run-at-time emms-listenbrai= nz-scrobbler-connection-timeout nil<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 #'emms-listenbrainz-scrobbler--handle-flush-timeout<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 buffer remaining-listens)))<br> > +=C2=A0 =C2=A0 =C2=A0(error<br> > +=C2=A0 =C2=A0 =C2=A0 ;; Synchronous error (e.g. DNS failure)<br> > +=C2=A0 =C2=A0 =C2=A0 (message "Listenbrainz: connection error du= ring flush, %d listens remain queued."<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(length remain= ing-listens))<br> > +=C2=A0 =C2=A0 =C2=A0 (emms-listenbrainz-scrobbler--write-queue remain= ing-listens)<br> > +=C2=A0 =C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler-flushing nil))= ))))<br> > +<br> > +=0C<br> > +;;; Interactive Commands<br> > +<br> > +(defun emms-listenbrainz-scrobbler-queue-count ()<br> > +=C2=A0 "Display and return the number of listens in the offline = queue."<br> > +=C2=A0 (interactive)<br> > +=C2=A0 (let ((count (length (emms-listenbrainz-scrobbler--read-queue)= )))<br> > +=C2=A0 =C2=A0 (message "Listenbrainz: %d listens queued for subm= ission." count)<br> > +=C2=A0 =C2=A0 count))<br> > +<br> > +(defun emms-listenbrainz-scrobbler-clear-queue ()<br> > +=C2=A0 "Clear all listens from the offline queue.<br> > +This discards listens that have not yet been submitted.=C2=A0 This ac= tion<br> > +cannot be undone."<br> > +=C2=A0 (interactive)<br> > +=C2=A0 (when (yes-or-no-p "Discard all queued offline listens? &= quot;)<br> > +=C2=A0 =C2=A0 (emms-listenbrainz-scrobbler--write-queue nil)<br> > +=C2=A0 =C2=A0 (message "Listenbrainz: offline queue cleared.&quo= t;)))<br> >=C2=A0 <br> >=C2=A0 <br> >=C2=A0 ;;* Hooks<br> > @@ -230,11 +527,15 @@ That is, if it has been played for 240 seconds o= r half the length of the track."<br> >=C2=A0 <br> >=C2=A0 <br> >=C2=A0 ;;* Entry points<br> > +<br> >=C2=A0 (defun emms-listenbrainz-scrobbler-enable ()<br> > -=C2=A0 "Enable the scrobbler and submit played tracks."<br> > +=C2=A0 "Enable the scrobbler and submit played tracks.<br> > +Also starts a periodic timer to flush any queued offline listens, and= <br> > +performs an initial flush attempt."<br> >=C2=A0 =C2=A0 (interactive)<br> >=C2=A0 =C2=A0 ;; We rely on emms-playing-time so check for it!<br> > -=C2=A0 (unless emms-playing-time-mode (error "Listenbrainz scrob= bler: please activate emms-playing-time-mode"))<br> > +=C2=A0 (unless emms-playing-time-mode<br> > +=C2=A0 =C2=A0 (error "Listenbrainz scrobbler: please activate em= ms-playing-time-mode"))<br> >=C2=A0 =C2=A0 ;; check we have credentials<br> >=C2=A0 =C2=A0 (if (emms-listenbrainz-scrobbler-get-token)<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 (unless emms-listenbrainz-scrobbler-running= <br> > @@ -244,7 +545,15 @@ That is, if it has been played for 240 seconds or= half the length of the track."<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0'emms= -listenbrainz-scrobbler-stop-hook)<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0(add-hook 'emms-player-finished-hook<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0'emms= -listenbrainz-scrobbler-stop-hook)<br> > -=C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-running t))<br> > +=C2=A0 =C2=A0 =C2=A0;; Start periodic queue flushing<br> > +=C2=A0 =C2=A0 =C2=A0(when emms-listenbrainz-scrobbler-flush-interval<= br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-queue-ti= mer<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0(run-at-time t<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 =C2=A0 emms-listenbrainz-scrobbler-flush-interval<br> > +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 =C2=A0 #'emms-listenbrainz-scrobbler-flush-queue)))<br> > +=C2=A0 =C2=A0 =C2=A0(setq emms-listenbrainz-scrobbler-running t)<br> > +=C2=A0 =C2=A0 =C2=A0;; Flush any listens queued from previous session= s<br> > +=C2=A0 =C2=A0 =C2=A0(emms-listenbrainz-scrobbler-flush-queue))<br> >=C2=A0 =C2=A0 =C2=A0 (error "Listenbrainz scrobbler: no user token= .=C2=A0 Please supply and try again")))<br> >=C2=A0 <br> >=C2=A0 (defun emms-listenbrainz-scrobbler-disable ()<br> > @@ -257,6 +566,10 @@ That is, if it has been played for 240 seconds or= half the length of the track."<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 'emms-liste= nbrainz-scrobbler-stop-hook)<br> >=C2=A0 =C2=A0 =C2=A0 (remove-hook 'emms-player-finished-hook<br> >=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 'emms-liste= nbrainz-scrobbler-stop-hook)<br> > +=C2=A0 =C2=A0 ;; Cancel periodic flush timer<br> > +=C2=A0 =C2=A0 (when emms-listenbrainz-scrobbler-queue-timer<br> > +=C2=A0 =C2=A0 =C2=A0 (cancel-timer emms-listenbrainz-scrobbler-queue-= timer)<br> > +=C2=A0 =C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler-queue-timer ni= l))<br> >=C2=A0 =C2=A0 =C2=A0 (setq emms-listenbrainz-scrobbler-running nil)))<b= r> <br> -- <br> =C2=A0 =C2=A0"Cut your own wood and it will warm you twice"<br> </blockquote></div> --0000000000008836dd065263ecab--