package require http package require tls 1.7 package require json # # Instructions for use: # 0. Make sure additional dependencies are installed: # sudo apt install tcl-tls tcllib # 1. edit Makefile or systemctl file to set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET # 2. Run folk # 3. Open folk-whatever.local:8888 and login to spotify # 4. Now folk programs can control spotify # Wish "spotify:track:232Y5lAjifWnc5NhUn34mz" would be played on spotify # When spotify is currently playing /song/ { # Wish $this is labelled "Playing: $song" # } http::register https 443 [list ::tls::socket -autoservername true] proc ::handleConnectSpotify {chan addr port} { fileevent $chan readable [list ::handleReadSpotify $chan $addr $port] } # TODO: Catch errors & return 501 proc ::handlePageSpotify {path contentTypeVar} { upvar $contentTypeVar contentType if {($path eq "/") || ([string match {/\?*} $path])} { return { Authorization Code flow with Spotify

Authorization Code flow

Log in with Spotify

oAuth info

Access token
Refresh token
} } subst { $path } } proc ::generateRandomString {length {chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"}} { set range [expr {[string length $chars]-1}] set txt "" for {set i 0} {$i < $length} {incr i} { set pos [expr {int(rand()*$range)}] append txt [string range $chars $pos $pos] } return $txt } proc ::handleReadSpotify {chan addr port} { chan configure $chan -translation crlf gets $chan line; set firstline $line puts "Http: $chan $addr $port: $line" set headers [list] while {[gets $chan line] >= 0 && $line ne ""} { if {[regexp -expanded {^( [^\s:]+ ) \s* : \s* (.+)} $line -> k v]} { lappend headers $k $v } else { break } } set tclHostname [info hostname] set hostnameWithoutDotLocal [string map {".local" ""} $tclHostname] set redirectUrl "http://$hostnameWithoutDotLocal.local:8888/callback" set redirectUrlEncoded "http%3A%2F%2F$hostnameWithoutDotLocal.local%3A8888%2Fcallback" set clientId $::env(SPOTIFY_CLIENT_ID) set clientSecret $::env(SPOTIFY_CLIENT_SECRET) if {[regexp {GET ([^ ]*) HTTP/1.1} $firstline -> path]} { set contentType "text/html; charset=utf-8" puts "Path: $path" if {($path eq "/") || ([string match {/\?*} $path])} { set response [::handlePageSpotify $path contentType] puts -nonewline $chan "HTTP/1.1 200 OK\nConnection: close\nContent-Type: $contentType\n\n" chan configure $chan -encoding binary -translation binary puts -nonewline $chan $response } elseif {$path eq "/login"} { set state [::generateRandomString 16] set responseTypeParam "response_type=code" set redirectUrlParam "redirect_uri=$redirectUrlEncoded" set clientIdParam "client_id=$clientId" set scopeParam "scope=user-read-private%20user-read-email%20user-read-currently-playing%20user-modify-playback-state" set stateParam "state=$state" puts -nonewline $chan "HTTP/1.1 302 Found\nSet-Cookie: spotify_auth_state=$state\nLocation: https://accounts.spotify.com/authorize?$responseTypeParam&$clientIdParam&$scopeParam&$redirectUrlParam&$stateParam" } elseif {[string match "/callback*" $path]} { regexp {/?code=(.+)&state=(.+)} $path qpFullMatch qpCode qpState set encodedAuthorization [binary encode base64 "$clientId:$clientSecret"] dict set hd "Content-Type" "application/x-www-form-urlencoded" dict set hd Authorization "Basic $encodedAuthorization" set token [::http::geturl "https://accounts.spotify.com/api/token" -headers $hd -query [::http::formatQuery grant_type authorization_code redirect_uri $redirectUrl code $qpCode]] set responseBody [::http::data $token] ::http::cleanup $token puts $responseBody set resultData [::json::json2dict $responseBody] set accessToken [dict get $resultData access_token] set refreshToken [dict get $resultData refresh_token] set ::spotifyAccessToken $accessToken puts -nonewline $chan "HTTP/1.1 302 Found\nSet-Cookie: spotify_auth_state=0; Max-Age=0\nLocation: /?access_token=$accessToken&refresh_token=$refreshToken" } elseif {[string match "/refresh_token*" $path]} { regexp {/?refresh_token=(.+)} $path qpFullMatch qpRefreshToken set encodedAuthorization [binary encode base64 "$clientId:$clientSecret"] dict set hd "Content-Type" "application/x-www-form-urlencoded" dict set hd Authorization "Basic $encodedAuthorization" set token [::http::geturl "https://accounts.spotify.com/api/token" -headers $hd -query [::http::formatQuery grant_type refresh_token refresh_token $qpRefreshToken]] set responseBody [::http::data $token] ::http::cleanup $token puts $responseBody set resultData [::json::json2dict $responseBody] set accessToken [dict get $resultData access_token] set ::spotifyAccessToken $accessToken set response "{\"access_token\": \"$accessToken\"}" puts -nonewline $chan "HTTP/1.1 200 OK\nConnection: close\nContent-Type: application/json; charset=utf-8\n\n" chan configure $chan -encoding binary -translation binary puts -nonewline $chan $response } close $chan } else { puts "Closing: $chan $addr $port $headers"; close $chan } } if {[catch {set ::serverSockSpotify [socket -server ::handleConnectSpotify 8888]}] == 1} { error "There's already a Web-capable Folk node running on this machine." } set ::spotifyAccessToken "" When /someone/ wishes /uri/ would be played on spotify { if {$::spotifyAccessToken eq ""} { puts "spotifyAccessToken is empty" } else { dict set hd "Content-Type" "application/json" dict set hd Authorization "Bearer $::spotifyAccessToken" puts "Requesting play of $uri on Spotify" # "spotify:album:04HMMwLmjkftjWy7xc6Bho" set queryParam "{\"context_uri\": \"$uri\"}" if {[string match "*track*" $uri]} { # "spotify:track:232Y5lAjifWnc5NhUn34mz" set queryParam "{\"uris\": \[\"$uri\"\]}" } set token [::http::geturl "https://api.spotify.com/v1/me/player/play" -method PUT -headers $hd -query $queryParam] set responseBody [::http::data $token] ::http::cleanup $token puts $responseBody } } proc ::pollCurrentSong {} { puts "Polling current song" if {$::spotifyAccessToken eq ""} { puts "spotifyAccessToken is empty" } else { dict set hd "Content-Type" "application/json" dict set hd Authorization "Bearer $::spotifyAccessToken" set token [::http::geturl "https://api.spotify.com/v1/me/player/currently-playing" -headers $hd] set responseBody [::http::data $token] puts $responseBody set responseCode [::http::ncode $token] ::http::cleanup $token puts $responseCode if {$responseCode == 204} { Retract spotify claims spotify is currently playing /something/ Assert spotify claims spotify is currently playing {} puts "done doing the thing, for nothing" } else { set resultData [::json::json2dict $responseBody] set item [dict get $resultData item] set songTitle [dict get $item name] set artists [dict get $item artists] set artistsNames {} foreach element $artists {lappend artistsNames [dict get $element name]} set artistsString [join $artistsNames ", "] Retract spotify claims spotify is currently playing /something/ Assert spotify claims spotify is currently playing "$songTitle by $artistsString" puts "done doing the thing" } } after 20000 [list ::pollCurrentSong] } ::pollCurrentSong