Emacs, Spotify, org-mode, and the web
Introduction
I’ve started constructing a system to let me publish information to the web using org-mode documents.
This documents how I communicate with the Spotify api using elisp and use that api to generate html.
Spotify elisp client
There are some existing libraries out there that are pretty easy to leverage but they’re focused more on playback control and using emacs as a player controller. I also found that these libraries would break during long term use any time spotify made an update to their services, so I opted to roll my own to better understand what is actually happening in it.
Configuring a Spotify app
To get access to the API we must first create an app on this form.
The important part is the Redirect URIs
I set mine to http://127.0.0.1:9001/mspotify
This is all configurable in the library, but thats the default setting the library is setup for.
Under permissions, just request Web API, Web Playback SDK, and iOS.
Once the app is created you will be given a Client ID and a Client Secret.
Record those somewhere.
Source code
We need a handful of libraries to authenticate and communicate with Spotify.
The authorization flow flow involves navigating a user to Spotify’s authentication system, having the user grant permission to access their account, getting navigated back to a callback url, retrieving a token thats issued during that navigation, and then requesting an access token.
Since we’re running this in a “text editor”, we need a way to listen for http requests.
For this I’m using simple-httpd.
(require 'simple-httpd nil t) (require 'json) (require 'url)
Then we define some library formalities and variables that we’ll be using to make everything work.
(defgroup mspotify nil "Access to Spotify API and clients" :group 'applications :prefix "mspotify-") (defcustom mspotify-http-server-port 9001 "Port number to start the http server on" :type 'integer) (defvar mspotify-token-file (expand-file-name ".cache/spotify-tokens.el" default-directory) "File to store Spotify authentication tokens.") (defvar mspotify-spotify-api-url "https://api.spotify.com/v1" "End-point to access Spotify's REST API.") (defvar mspotify-spotify-authorize-url "https://accounts.spotify.com/authorize" "End-point to access Spotify's authorize flow") (defvar mspotify-spotify-api-authentication-url "https://accounts.spotify.com/api/token" "End-point to exchange authorization code for access token") (defvar mspotify-spotify-api-redirect-uri (format "http://127.0.0.1:%s/mspotify" mspotify-http-server-port) "URL the spotify will redirect to after authorization") (defvar mspotify-client-user-authorization-token "" "Spotify token returned from API after user grants authorization to the app") (defvar mspotify-client-user-access-token "" "Spotify token retrieved AFTER receiving the authorization token") (defvar mspotify-token-timestamp nil "Timestamp when the access token was last refreshed") (defvar mspotify-client-user-refresh-token "" "Refresh token retrieved AFTER receiving the authorization token") (defvar mspotify-access-data nil "Data returned from requesting access from a client") (defvar mspotify-client-user-authorization-error nil)
Then we define some variable helpers to trigger some side effects when they are mutated.
(defun set-mspotify-client-user-authorization-token (value) "Sets spotify auth token with side effects" (setq mspotify-client-user-authorization-token value) (mspotify-request-access-token value)) (defun set-mspotify-client-user-access-token (value) "Sets spotify access token with side effects" (setq mspotify-client-user-access-token value) (setq mspotify-token-timestamp (current-time))) (defun set-mspotify-client-user-refresh-token (value) "Sets spotify refresh token with side effects" (setq mspotify-client-user-refresh-token value)) (defun set-mspotify-access-data (value) "Sets spotify access data from successful access fetch" (setq mspotify-access-data value) (set-mspotify-client-user-access-token (assoc-default 'access_token value)) (set-mspotify-client-user-refresh-token (assoc-default 'refresh_token value)) ;; Save tokens to file after successful authentication (mspotify-save-tokens)) (defun set-mspotify-client-user-authorization-error (value) "Set when an error is received during authorization" (setq mspotify-client-user-authorization-error value) (message "mSpotify Authorization Error: %s" value))
I’m not a lisp or functional programming expert but the thinking is that by mutating one variable I can kick off side effects without having to write a bunch of extra code. There is probably a better way to do it, but I didn’t want to think too hard outside of accomplishing the task at hand.
We continue by defining more variables.
(defvar mspotify-spotify-api-scopes '("user-read-playback-state" "user-modify-playback-state" "user-read-currently-playing" "app-remote-control" "playlist-read-private" "playlist-read-collaborative" "user-read-playback-position" "user-top-read" "user-read-recently-played" "user-library-read") "Authorization scopes we request for the user from spotify") (defcustom mspotify-client-id "" "Spotify application client ID. Set by YOU." :type 'string) (defcustom mspotify-client-secret "" "Spotify application client secret. You set this." :type 'string)
Now comes the httpd server management.
This allows us to start and stop a locally running httpd server.
(setq mspotify--http-server-running nil) (defun mspotify--start-http-server (callback) "Starts a http server for receiving authentication requests" (if (featurep 'simple-httpd) (progn ;; Don't set an http root (setq httpd-root nil) ;; Set the port we want to use (setq httpd-port mspotify-http-server-port) ;; Set a callback hook for when the server starts (setq httpd-start-hook (lambda () (setq mspotify--http-server-running t) (funcall callback))) (httpd-start)) (error "simple-httpd is required for OAuth authentication. Install it to use mspotify-connect"))) (defun mspotify--stop-http-server () "Stops a running http server" (when (featurep 'simple-httpd) (setq httpd-stop-hook (lambda () (setq mspotify--http-server-running nil))) (httpd-stop)))
And this is a handler we define to respond to requests on the /mspotify endpoint.
The defservlet macro defines paths by function names.
I might dig into it later to make this configurable so a user can use any path they choose, but for now this works.
It parses out the query params passed to it, sets the appropriate variables, responds with got it and then stops the server.
;; Only define servlet if simple-httpd is available (when (featurep 'simple-httpd) (defservlet mspotify text/plain (_path query-alist) "Servlet for simple-httpd to retrieve the spotify auth code" (dolist (param query-alist) (let* ((key (car param)) (values (cdr param)) (value (car values))) (when (string= key "code") (set-mspotify-client-user-authorization-token value)) (when (string= key "state") (setq mspotify-user-authorize-state value)) (when (string= key "error") (set-mspotify-client-user-authorization-error value)))) (insert "got it") (mspotify--stop-http-server)))
We’re almost at the core authorization point.
We need some helpers to build our request first.
(defun mspotify-gen-random-string (length) "Generate a random alphanumeric string of LENGTH." (let ((chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") (result "")) (dotimes (_ length) (setq result (concat result (string (elt chars (random (length chars))))))) result)) (defun mspotify-build-authorization-query-string () "Builds the query string for authorize" (url-build-query-string `( (response_type code) (client_id ,mspotify-client-id) (scope ,(mapconcat 'identity mspotify-spotify-api-scopes " ")) (redirect_uri ,mspotify-spotify-api-redirect-uri) (state ,(mspotify-gen-random-string 32))))) (defun mspotify-build-authorization-header-value () "Buils a Basic Authorization header value using client id and secret" (format "Basic %s" (base64-encode-string (concat mspotify-client-id ":" mspotify-client-secret) ;; don't insert newline t))) (defun mspotify-build-access-token-header-value () "Builds a Basic Authorization header value using the client's access token" (format "Bearer %s" mspotify-client-user-access-token))
And then the core “connect” function.
This starts a web server, waits for it to become available, and then navigates the user’s web browser to Spotify’s authorize endpoint.
The user can choose to grant permission to the app we’ve created at which point the Spotify api will redirect the user to our redirect_uri defined by the mspotify-spotify-api-redirect-uri variable.
Our local web server receives the redirect and parses out the query params Spotify includes with it.
This is an (interactive) function which means you can type M-x mspotify-connect to run it.
(defun mspotify-connect () "Start an http server and forward the user to grant authorization" (interactive) (mspotify--start-http-server (lambda () (browse-url (format "%s?%s" mspotify-spotify-authorize-url (mspotify-build-authorization-query-string))))))
If you go back and look at our set-mspotify-client-user-authorization-token function you’ll see that it calls (mspotify-request-access-token value) after mutating the variable.
The code for that looks like this
(defun mspotify-request-access-token (authorization_code) "Request an access token using the auth code form the authorization call" (let ((url-request-method "POST") (url-request-extra-headers `(("Content-Type" . "application/x-www-form-urlencoded") ("Authorization" . ,(mspotify-build-authorization-header-value)))) (url-request-data (url-build-query-string `(("code" ,authorization_code) ("redirect_uri" ,mspotify-spotify-api-redirect-uri) ("grant_type" "authorization_code"))))) (url-retrieve mspotify-spotify-api-authentication-url (lambda (_status) (let ((status-code url-http-response-status)) (if (not (= status-code 200)) (message "Spotify request failed with status: %d" status-code)) (let* ((json-body (buffer-substring-no-properties url-http-end-of-headers (point-max))) (data (json-parse-string json-body :object-type 'alist))) (let ((access-data data)) (set-mspotify-access-data access-data))))))))
The above takes the token returned to us from the Spotify API and uses it to request an actual token that we can include to make API calls.
Now we are free to interact with the Spotify API and gather information.
Let’s create a helper for doing that.
(defun mspotify--request (method path &optional params body callback) "Core async request method for Spotify API. METHOD is a string: \"GET\", \"POST\", etc. PATH is the endpoint like \"/me\" or \"/me/player\". PARAMS is an alist of query parameters. BODY is raw POST/PUT body (string) or nil. CALLBACK is (lambda (data)) where data is parsed JSON." ;; Ensure token is fresh before making request (mspotify-ensure-token-fresh) (let* ((url-request-method method) (url-request-extra-headers `(("Authorization" . ,(mspotify-build-access-token-header-value)) ("Content-Type" . "application/json"))) (url-request-data body) (query (and params (concat "?" (url-build-query-string params)))) (url (concat mspotify-spotify-api-url path (or query "")))) (url-retrieve url (lambda (_state) (goto-char (point-min)) ;; Check for 401 Unauthorized response (let ((status-code (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) (string-to-number (match-string 1))))) (goto-char url-http-end-of-headers) (let* ((json-body (buffer-substring-no-properties (point) (point-max))) (data (ignore-errors (json-parse-string json-body :object-type 'alist)))) (kill-buffer (current-buffer)) ;; If we got a 401, try refreshing token once (when (and status-code (= status-code 401)) (message "Got 401 response, refreshing token...") (mspotify-refresh-access-token-sync) ;; Note: We can't easily retry the request here since we're in a callback ;; The next request should work with the new token (message "Token refreshed. Next request should work.")) (when callback (funcall callback data)))))))) (defun mspotify--get (path &optional params callback) "Builds a GET request to the PATH with optional PARAMS and a CALLBACK" (mspotify--request "GET" path params nil callback)) (defun mspotify--post (path &optional body callback) "Builds a POST request to the PATH with optional BODY and a CALLBACK" (mspotify--request "POST" path nil (and body (json-serialize body)) callback))
Now that we have a means of making http requests we can create a bunch of helper functions to reach various endpoints
(defun mspotify-get-current-user (callback) "Retrieves the current user's profile" (mspotify--get "/me" nil callback)) (defun mspotify-get-player-state (callback) "Retrieves the current users's player state" (mspotify--get "/me/player" nil callback)) (defun mspotify-get-playlists (callback) "Retrieves the current user's playlists" (mspotify--get "/me/playlists" nil callback))
When can get more interesting endpoints and conditionally add params as well
(defun mspotify-get-top-tracks (callback &optional time-range limit) "Get user's top tracks. TIME-RANGE can be short_term, medium_term, or long_term. LIMIT is max number of items to return (default 20, max 50)." (let ((params `(,@(when time-range `(("time_range" ,time-range))) ,@(when limit `(("limit" ,(number-to-string limit))))))) (mspotify--get "/me/top/tracks" params callback))) (defun mspotify-get-top-artists (callback &optional time-range limit) "Get user's top artists. TIME-RANGE can be short_term, medium_term, or long_term. LIMIT is max number of items to return (default 20, max 50)." (let ((params `(,@(when time-range `(("time_range" ,time-range))) ,@(when limit `(("limit" ,(number-to-string limit))))))) (mspotify--get "/me/top/artists" params callback))) (defun mspotify-get-recently-played (callback &optional limit) "Get user's recently played tracks. LIMIT is max number of items (default 20, max 50)." (let ((params (when limit `(("limit" ,(number-to-string limit)))))) (mspotify--get "/me/player/recently-played" params callback))) (defun mspotify-get-liked-songs (callback &optional limit offset) "Get user's liked songs. LIMIT is max items (default 20, max 50). OFFSET is the index to start from (for pagination)." (let ((params `(,@(when limit `(("limit" ,(number-to-string limit)))) ,@(when offset `(("offset" ,(number-to-string offset))))))) (mspotify--get "/me/tracks" params callback)))
One thing to be aware of is that Spotify tokens expire.
When we first request an access token from the user we also get an expiry time and a refresh token we can use to request a new one.
I should really be putting them somewhere safe like authinfo.gpg or something like that, but I don’t want to fiddle with that just quite yet on this machine and it would still ask for some unrelated input for me to unlock it. So that will be a topic for another post.
For the time being here are some helper functions for insecurely storing the tokens.
;;; Token Persistence Functions (defun mspotify-save-tokens () "Save authentication tokens to file." (let ((cache-dir (file-name-directory mspotify-token-file))) ;; Create cache directory if it doesn't exist (unless (file-exists-p cache-dir) (make-directory cache-dir t)) ;; Save tokens to file (with-temp-file mspotify-token-file (insert ";;; Spotify Authentication Tokens\n") (insert ";;; This file is auto-generated. DO NOT EDIT.\n\n") (prin1 `(setq mspotify-client-user-access-token ,mspotify-client-user-access-token) (current-buffer)) (insert "\n") (prin1 `(setq mspotify-client-user-refresh-token ,mspotify-client-user-refresh-token) (current-buffer)) (insert "\n") (prin1 `(setq mspotify-token-timestamp ',mspotify-token-timestamp) (current-buffer)) (insert "\n") (when mspotify-access-data (prin1 `(setq mspotify-access-data ',mspotify-access-data) (current-buffer)) (insert "\n"))) ;; Set file permissions to 600 (read/write for owner only) (set-file-modes mspotify-token-file #o600) (message "Spotify tokens saved to %s" mspotify-token-file))) (defun mspotify-load-tokens () "Load authentication tokens from file." (when (file-exists-p mspotify-token-file) (condition-case err (progn (load-file mspotify-token-file) (if (and mspotify-client-user-access-token (not (string-empty-p mspotify-client-user-access-token))) (message "Spotify tokens loaded from %s" mspotify-token-file) (message "No valid tokens found in %s" mspotify-token-file))) (error (message "Error loading Spotify tokens: %s" err))))) (defun mspotify-clear-tokens () "Clear tokens from memory and delete token file." (interactive) (setq mspotify-client-user-access-token "") (setq mspotify-client-user-refresh-token "") (setq mspotify-access-data nil) (when (file-exists-p mspotify-token-file) (delete-file mspotify-token-file) (message "Spotify tokens cleared"))) ;; Automatically load tokens when this file is loaded (mspotify-load-tokens)
And some helpers to refresh them
(defun mspotify-token-expired-p () "Check if the access token has expired (older than 55 minutes)." (when mspotify-token-timestamp (let* ((current (current-time)) (elapsed (time-subtract current mspotify-token-timestamp)) (minutes (/ (float-time elapsed) 60))) (> minutes 55)))) (defun mspotify-ensure-token-fresh () "Ensure the access token is fresh, refreshing if needed." (when (and mspotify-client-user-refresh-token (not (string-empty-p mspotify-client-user-refresh-token))) (when (or (not mspotify-client-user-access-token) (string-empty-p mspotify-client-user-access-token) (mspotify-token-expired-p)) (message "Token expired or missing, refreshing...") (mspotify-refresh-access-token-sync)))) (defun mspotify-refresh-access-token-sync () "Synchronously refresh the access token and wait for completion." (let ((done nil) (success nil)) (mspotify-refresh-access-token) ;; Wait for async refresh to complete (max 5 seconds) (let ((timeout 5.0) (start (current-time))) (while (and (not done) (< (float-time (time-subtract (current-time) start)) timeout)) (sleep-for 0.1) (when (and mspotify-token-timestamp (< (float-time (time-subtract (current-time) mspotify-token-timestamp)) 2)) (setq done t success t)))) success)) (defun mspotify-refresh-access-token () "Use refresh token to get a new access token." (interactive) (if (or (not mspotify-client-user-refresh-token) (string-empty-p mspotify-client-user-refresh-token)) (message "No refresh token available. Please run M-x mspotify-connect to authenticate.") (let ((url-request-method "POST") (url-request-extra-headers `(("Content-Type" . "application/x-www-form-urlencoded") ("Authorization" . ,(mspotify-build-authorization-header-value)))) (url-request-data (url-build-query-string `(("grant_type" "refresh_token") ("refresh_token" ,mspotify-client-user-refresh-token))))) (url-retrieve mspotify-spotify-api-authentication-url (lambda (_status) (goto-char url-http-end-of-headers) (let* ((json-body (buffer-substring-no-properties (point) (point-max))) (data (json-parse-string json-body :object-type 'alist)) (new-access-token (assoc-default 'access_token data))) (if new-access-token (progn (set-mspotify-client-user-access-token new-access-token) ;; Update access data with new token (when mspotify-access-data (setf (alist-get 'access_token mspotify-access-data) new-access-token)) (mspotify-save-tokens) (message "Access token refreshed successfully")) (message "Failed to refresh access token: %s" json-body))))))))
And that’s it.
The Web.
Well, that was boring.
But it was necessary. The previous section is what actually builds the mspotify.el file that we execute.
This blog post is actually the document I’m going to use to manage the library going forward.
Any changes made to the code on this very page will update the library.
So in a sense I’m not writing to document what I’m building, but rather writing to build.
This isn’t a new concept. It’s been around since 1984 and is called literate programming.
I think this may become important because AI tools like Claude and ChatGPT etc are already doing something similar in a way.
I’m just doing it manually and retaining the output as a foundation for spiraling out into something. Not quite sure what yet.