annotate cpack/dml/lib/spotify/spotify.pl @ 0:718306e29690 tip

commiting public release
author Daniel Wolff
date Tue, 09 Feb 2016 21:05:06 +0100
parents
children
rev   line source
Daniel@0 1 /* Part of DML (Digital Music Laboratory)
Daniel@0 2 Copyright 2014-2015 Samer Abdallah, University of London
Daniel@0 3
Daniel@0 4 This program is free software; you can redistribute it and/or
Daniel@0 5 modify it under the terms of the GNU General Public License
Daniel@0 6 as published by the Free Software Foundation; either version 2
Daniel@0 7 of the License, or (at your option) any later version.
Daniel@0 8
Daniel@0 9 This program is distributed in the hope that it will be useful,
Daniel@0 10 but WITHOUT ANY WARRANTY; without even the implied warranty of
Daniel@0 11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Daniel@0 12 GNU General Public License for more details.
Daniel@0 13
Daniel@0 14 You should have received a copy of the GNU General Public
Daniel@0 15 License along with this library; if not, write to the Free Software
Daniel@0 16 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Daniel@0 17 */
Daniel@0 18
Daniel@0 19 :- module(spotify,
Daniel@0 20 [ spotify_authorise/1
Daniel@0 21 , spotify_app/2
Daniel@0 22 , spotify/2
Daniel@0 23 , spotify_user/2
Daniel@0 24 , spotify_search/4
Daniel@0 25 , spotify_player//2
Daniel@0 26 , spotify_player_url/3
Daniel@0 27 ]).
Daniel@0 28
Daniel@0 29 /** <module> Interface to Spotify Web API
Daniel@0 30
Daniel@0 31 This module provides access to the Spotify web API.
Daniel@0 32 It uses dicts, but does not require the .field syntax to be enabled.
Daniel@0 33
Daniel@0 34 https://developer.spotify.com/web-api/
Daniel@0 35
Daniel@0 36 */
Daniel@0 37
Daniel@0 38 :- use_module(library(http/http_dispatch)).
Daniel@0 39 :- use_module(library(http/thread_httpd)).
Daniel@0 40 :- use_module(library(http/http_parameters)).
Daniel@0 41 :- use_module(library(http/http_ssl_plugin)).
Daniel@0 42 :- use_module(library(http/html_write)).
Daniel@0 43 :- use_module(library(base64)).
Daniel@0 44 :- use_module(library(dcg_core)).
Daniel@0 45 :- use_module(library(webby)).
Daniel@0 46 :- use_module(library(insist)).
Daniel@0 47 :- use_module(library(state)).
Daniel@0 48
Daniel@0 49 :- multifile app_facet/2.
Daniel@0 50 :- dynamic state/2.
Daniel@0 51 :- set_prolog_flag(double_quotes,string).
Daniel@0 52
Daniel@0 53 :- http_handler(root(authorise), authorise, []).
Daniel@0 54
Daniel@0 55 %% spotify_app(+App:ground, +Spec:list) is det.
Daniel@0 56 %
Daniel@0 57 % Declares App to be a registered Spotify application. Spec must contain contain
Daniel@0 58 % the following elements containing information obtained during the application
Daniel@0 59 % registration process. They can be atoms or strings.
Daniel@0 60 % * id(ClientID:text)
Daniel@0 61 % * secret(Secret:text)
Daniel@0 62 % * redirect(RedirectURL:text)
Daniel@0 63 % * scope(Scopes:text)
Daniel@0 64 % This can only be used as a directive, as the expansion process included declaring
Daniel@0 65 % an HTTP handler using http_handler/3.%
Daniel@0 66 spotify_app(_,_) :- throw(directive(spotify_app/2)).
Daniel@0 67
Daniel@0 68 user:term_expansion((:- spotify_app(App,Props)), Clauses) :- !,
Daniel@0 69 Info=[id(_),secret(_),redirect(Redirect),scope(_)],
Daniel@0 70 maplist(spotify:option_inv(Props),Info),
Daniel@0 71 insist(url:parse_url(Redirect,Parts),bad_redirect(Redirect)),
Daniel@0 72 member(path(Path),Parts),
Daniel@0 73 seqmap(spotify:app_clause(App),Info,Clauses,[(:-http_handler(Path, spotify:callback(App), []))]).
Daniel@0 74
Daniel@0 75 option_inv(Props,Prop) :- insist(option(Prop,Props),missing_app_facet(Prop)).
Daniel@0 76 app_clause(App,Prop) --> [spotify:app_facet(App,Prop)].
Daniel@0 77
Daniel@0 78 app_port(App,Port) :-
Daniel@0 79 app_facet(App,redirect(Redirect)),
Daniel@0 80 parse_url(Redirect,Parts),
Daniel@0 81 member(port(Port),Parts).
Daniel@0 82
Daniel@0 83
Daniel@0 84 %% spotify_authorise(+App) is det.
Daniel@0 85 %
Daniel@0 86 % Get authorisation for current app from a Spotify user.
Daniel@0 87 % This requires a working www_open_url to initiate an interaction with
Daniel@0 88 % the user to handle the Spotify login process. If this doesn't
Daniel@0 89 % work, consider changing the 'browser' Prolog flag. For example,
Daniel@0 90 % on Mac OS X, add the following to your ~/.plrc
Daniel@0 91 % ==
Daniel@0 92 % :- set_prolog_flag(browser,open).
Daniel@0 93 % ==
Daniel@0 94 % After logging in (if necessary) and authorising the app, the browser
Daniel@0 95 % should show a page confirming that the process succeeded and giving information
Daniel@0 96 % about the current user. This predicate (spotify_authorise/0) will wait
Daniel@0 97 % until the confirmation page has been shown. It may hang if something
Daniel@0 98 % goes wrong.
Daniel@0 99 spotify_authorise(App) :-
Daniel@0 100 get_time(Time), variant_sha1(App-Time,Hash),
Daniel@0 101 login_url(App,Hash,URL),
Daniel@0 102 app_port(App,Port),
Daniel@0 103 with_message_queue(spotify,[max_size(5)],
Daniel@0 104 with_http_server(Port,
Daniel@0 105 ( thread_send_message(spotify,login(App,Hash,URL)),
Daniel@0 106 print_message(information,spotify:auth_opening_browser(App,Port,URL)),
Daniel@0 107 www_open_url(URL),
Daniel@0 108 print_message(information,spotify:auth_waiting(App)),
Daniel@0 109 thread_get_message(spotify,cont(App,Hash,RC),[timeout(300)]),
Daniel@0 110 (RC=error(Ex) -> throw(Ex); true),
Daniel@0 111 print_message(information,spotify:auth_complete(App))
Daniel@0 112 ))).
Daniel@0 113
Daniel@0 114
Daniel@0 115 with_http_server(Port,Goal) :-
Daniel@0 116 setup_call_cleanup(
Daniel@0 117 http_server(http_dispatch, [port(Port)]), Goal,
Daniel@0 118 http_stop_server(Port,[])).
Daniel@0 119
Daniel@0 120 with_message_queue(Alias,Opts,Goal) :-
Daniel@0 121 setup_call_cleanup(
Daniel@0 122 message_queue_create(_,[alias(Alias)|Opts]), Goal,
Daniel@0 123 message_queue_destroy(Alias)).
Daniel@0 124
Daniel@0 125 %% login_url(+App,+Hash,-URL) is det.
Daniel@0 126 login_url(App,Hash,URL) :-
Daniel@0 127 maplist(app_facet(App),[id(ID),redirect(Redirect),scope(Scope)]),
Daniel@0 128 Params=[ response_type=code,client_id=ID,scope=Scope, redirect_uri=Redirect, state=Hash ],
Daniel@0 129 parse_url(URL,[ protocol(https), host('accounts.spotify.com'), path('/authorize'), search(Params)]).
Daniel@0 130
Daniel@0 131
Daniel@0 132 %% authorise(Request) is det.
Daniel@0 133 %
Daniel@0 134 % Authorisation process via a web interface.
Daniel@0 135 authorise(Request) :-
Daniel@0 136 ( thread_peek_message(spotify,login(_,_,URL))
Daniel@0 137 -> http_redirect(see_other,URL,Request)
Daniel@0 138 ; reply_html_page(default, [title("SWI Prolog Spotify client")],
Daniel@0 139 center(p("Nothing to see here. Move on.")))).
Daniel@0 140
Daniel@0 141
Daniel@0 142 %% callback(+App,+Request) is det.
Daniel@0 143 %
Daniel@0 144 % Handle the callback from Spotify as part of the authorisation process.
Daniel@0 145 % This handler must be registered with library(http_dispatch) associated
Daniel@0 146 % with 'redirect' URL property of the current app.
Daniel@0 147 callback(App,Request) :-
Daniel@0 148 http_parameters(Request,[ code(Code, [string, optional(true)])
Daniel@0 149 , error(Error, [string, optional(true)])
Daniel@0 150 , error_description(ErrorDesc, [string, optional(true)])
Daniel@0 151 , state(Hash, [atom, optional(false)]) ]),
Daniel@0 152 debug(spotify,"Got callback (~w)",[Hash]),
Daniel@0 153 insist(thread_get_message(spotify,login(App,Hash,_),[timeout(0)]),no_matching_state),
Daniel@0 154
Daniel@0 155 catch(
Daniel@0 156 ( insist(var(Error), auth_error(Error,ErrorDesc)),
Daniel@0 157 insist(nonvar(Code),no_code_received),
Daniel@0 158 maplist(app_facet(App),[id(ID),secret(Secret),redirect(Redirect)]),
Daniel@0 159 get_authorisation([ grant_type='authorization_code'
Daniel@0 160 , code=Code, redirect_uri=Redirect
Daniel@0 161 , client_id=ID, client_secret=Secret
Daniel@0 162 ], [], Reply),
Daniel@0 163 debug(spotify,"Received access and refresh tokens.",[]),
Daniel@0 164 debug(spotify,"Posting success to login queue ~w...",[Hash]),
Daniel@0 165 Status=ok(Reply)
Daniel@0 166 ), Ex, Status=error(Ex)),
Daniel@0 167 handle_auth_reply(Status,App,Hash).
Daniel@0 168
Daniel@0 169 handle_auth_reply(ok(Reply),App,Hash) :-
Daniel@0 170 set_tokens(App,Reply),
Daniel@0 171 debug(spotify,"Posting success to login queue ~w...",[Hash]),
Daniel@0 172 thread_send_message(spotify,cont(App,Hash,ok)),
Daniel@0 173 spotify(App,me(Me)), is_dict(Me,user),
Daniel@0 174 set_state(user(App),Me),
Daniel@0 175 reply_html_page(default,
Daniel@0 176 [title("SWI-Prolog Spotify Client > Login ok")],
Daniel@0 177 [ \html_post(head,style(
Daniel@0 178 [ "table.json table.json { border: thin solid gray }"
Daniel@0 179 , "table.json { border-collapse: collapse }"
Daniel@0 180 , "table.json td:first-child { font-weight: bold; text-align: right; padding-right:0.5em }"
Daniel@0 181 , "table.json td { vertical-align:top; padding-left:0.5em }"
Daniel@0 182 , "body { margin:2em; }"
Daniel@0 183 ]))
Daniel@0 184 , center(div([h3("SWI Prolog library app '~w' authorised as user"-App), \json(Me) ]))
Daniel@0 185 ]).
Daniel@0 186
Daniel@0 187 handle_auth_reply(error(Ex),App,Hash) :-
Daniel@0 188 debug(spotify,"Posting error to login queue ~w...",[Hash]),
Daniel@0 189 thread_send_message(spotify,cont(App,Hash,error(Ex))),
Daniel@0 190 ( Ex=http_bad_status(_,Doc), phrase("<!DOCTYPE html>",Doc,_)
Daniel@0 191 -> format("Content-type: text/html; charset=UTF-8~n~n~s",[Doc])
Daniel@0 192 ; throw(Ex)
Daniel@0 193 ).
Daniel@0 194
Daniel@0 195 %% refresh is det.
Daniel@0 196 % Refresh the access token for the current app.
Daniel@0 197 refresh(App) :-
Daniel@0 198 app_facet(App,id(ID)),
Daniel@0 199 app_facet(App,secret(Secret)),
Daniel@0 200 get_state(refresh_token(App),Token),
Daniel@0 201 format(codes(IDCodes),"~w:~w", [ID,Secret]),
Daniel@0 202 phrase(("Basic ",base64(IDCodes)),AuthCodes),
Daniel@0 203 string_codes(Auth,AuthCodes),
Daniel@0 204 % debug(spotify,"Refreshing access tokens...",[]),
Daniel@0 205 get_authorisation([grant_type=refresh_token, refresh_token=Token],
Daniel@0 206 [request_header('Authorization'=Auth)], Reply),
Daniel@0 207 debug(spotify,"Received new access tokens.",[]),
Daniel@0 208 set_tokens(App,Reply).
Daniel@0 209
Daniel@0 210 %% set_tokens(+App,+Dict) is det.
Daniel@0 211 % Updates the record of the current access and refresh tokens,
Daniel@0 212 % and their new expiry time.
Daniel@0 213 set_tokens(App,Dict) :-
Daniel@0 214 _{expires_in:Expiry,access_token:Access} :< Dict,
Daniel@0 215 get_time(Now), ExpirationTime is Now+Expiry,
Daniel@0 216 set_state(access_token(App),Access-ExpirationTime),
Daniel@0 217 ( _{refresh_token:Refresh} :< Dict
Daniel@0 218 -> set_state(refresh_token(App),Refresh)
Daniel@0 219 ; true
Daniel@0 220 ).
Daniel@0 221
Daniel@0 222 %% usable_token(+App,-Token) is det.
Daniel@0 223 % Gets a usable access token, refreshing if the current one expired.
Daniel@0 224 usable_token(App,Token) :-
Daniel@0 225 get_state(access_token(App),Token0-ExpiryDate),
Daniel@0 226 get_time(Now),
Daniel@0 227 ( ExpiryDate>Now -> Token=Token0
Daniel@0 228 ; refresh(App), usable_token(App,Token)
Daniel@0 229 ).
Daniel@0 230
Daniel@0 231 %% spotify_player(+URI:atom,+Opts:options) is det.
Daniel@0 232 % HTML component for showing the Spotify web widget.
Daniel@0 233 spotify_player_url(track(URI),Opts,URL) :-
Daniel@0 234 option(theme(Th),Opts,white), must_be(oneof([white,black]),Th),
Daniel@0 235 option(view(Vw),Opts,list), must_be(oneof([list,coverart]),Vw),
Daniel@0 236 parse_url(URL, [ protocol(https),host('embed.spotify.com'),path(/)
Daniel@0 237 , search([uri=URI, theme=Th, view=Vw]) ]).
Daniel@0 238
Daniel@0 239 spotify_player_url(tracks(Title,URIs),Opts,URL) :-
Daniel@0 240 option(theme(Th),Opts,white), must_be(oneof([white,black]),Th),
Daniel@0 241 option(view(Vw),Opts,list), must_be(oneof([list,coverart]),Vw),
Daniel@0 242 maplist(string_concat('spotify:track:'),IDs,URIs),
Daniel@0 243 atomics_to_string(IDs,',',IDList),
Daniel@0 244 atomics_to_string([spotify,trackset,Title,IDList],':',URI),
Daniel@0 245 parse_url(URL, [ protocol(https),host('embed.spotify.com'),path(/)
Daniel@0 246 , search([uri=URI, theme=Th, view=Vw]) ]).
Daniel@0 247
Daniel@0 248 spotify_player(Spec,Opts) -->
Daniel@0 249 { spotify_player_url(Spec,Opts,URL),
Daniel@0 250 option(width(W),Opts,300), must_be(between(250,640),W),
Daniel@0 251 option(height(H),Opts,300), must_be(between(80,720),H)
Daniel@0 252 },
Daniel@0 253 html(iframe([ src=URL,width=W,height=H,frameborder=0,allowtransparency=true],[])).
Daniel@0 254
Daniel@0 255 % multiple tracks:
Daniel@0 256 % uri='spotify:trackset:PREFEREDTITLE:5Z7ygHQo02SUrFmcgpwsKW,1x6ACsKV4UdWS2FMuPFUiT'
Daniel@0 257 json(Dict) -->
Daniel@0 258 {is_dict(Dict), !, dict_pairs(Dict,_,Pairs)},
Daniel@0 259 html(table(class=json,
Daniel@0 260 [colgroup([col(style="border-right:thin solid black"),col([])]), \seqmap(pair_row,Pairs)])).
Daniel@0 261 json(List) --> {is_list(List)}, !, html(ol(\seqmap(val_li,List))).
Daniel@0 262 json(Val) --> html("~w"-Val).
Daniel@0 263 val_li(Val) --> html(li(\json(Val))).
Daniel@0 264 pair_row(Name-Val) --> html(tr( [ td(Name), td(\json(Val))])).
Daniel@0 265
Daniel@0 266 %% spotify(+App,+Request) is nondet.
Daniel@0 267 %
Daniel@0 268 % This is the main predicate for making calls to the Spotify web API. The nature of
Daniel@0 269 % the call, its parameters, and its results are encoded in the term Request.
Daniel@0 270 % See request/4 for information about what requests are recognised.
Daniel@0 271 % This predicate will formulate the call URL, refresh the access token if necessary,
Daniel@0 272 % make the call, and read the results.
Daniel@0 273 spotify(App,Req) :-
Daniel@0 274 request(Req,PathPhrase,Method,Reader), !,
Daniel@0 275 phrase(PathPhrase,PathParts),
Daniel@0 276 parts_path([v1|PathParts],Path),
Daniel@0 277 usable_token(App,Token),
Daniel@0 278 string_concat("Bearer ", Token, Auth),
Daniel@0 279 spotify(Method,Reader,'api.spotify.com',Path,
Daniel@0 280 [ request_header('Authorization'=Auth) ]).
Daniel@0 281
Daniel@0 282 % just for internal use
Daniel@0 283 get_authorisation(Params,Opts,Reply) :-
Daniel@0 284 spotify(post(form(Params)), json(Reply), 'accounts.spotify.com', '/api/token', Opts).
Daniel@0 285
Daniel@0 286 % All web calls come through here eventually.
Daniel@0 287 spotify(Method,Reader,Host,Path,Opts) :-
Daniel@0 288 restcall(Method, Reader,
Daniel@0 289 [ protocol(https), host(Host), path(Path) ],
Daniel@0 290 [ request_header('Accept'='application/json')
Daniel@0 291 , cert_verify_hook(spotify:verify)
Daniel@0 292 | Opts ]).
Daniel@0 293
Daniel@0 294 verify(_, Problem, _All, _First, Error) :-
Daniel@0 295 debug(ssl,"Accepting problem certificate (~w)\n~w\n",[Error,Problem]).
Daniel@0 296
Daniel@0 297 %% request(-Req:spotify_request, -PathPhrase:phrase(atom), -Method:web_method, -Reader:web_reader) is nondet.
Daniel@0 298 %
Daniel@0 299 % Database of mappings from requests to end-points.
Daniel@0 300 request( me(Me), [me], get([]), json(Me)).
Daniel@0 301 request( track(TID,T), [tracks,TID], get([]), json(T)).
Daniel@0 302 request( playlists(UID,Ls), playlists(UID), get([]), json(Ls)).
Daniel@0 303 request( playlist(UID,PID,L), playlist(UID,PID), get([]), json(L)).
Daniel@0 304 request( playlist_tracks(UID,PID,Ts), tracks(UID,PID), get([]), json(Ts)).
Daniel@0 305 request( search(Type,Term,L), [search], get([(type)=Type,q=Term]), json(L)).
Daniel@0 306 request( create_playlist(UID,Name,PL), playlists(UID), post(json(_{name:Name})), json(PL)).
Daniel@0 307 request( add_tracks(UID,PID,URIs), tracks(UID,PID), post(json(URIs)), nil).
Daniel@0 308 request( set_tracks(UID,PID,URIs), tracks(UID,PID), put(json(_{uris:URIs})), nil).
Daniel@0 309 request( del_tracks(UID,PID,TIDs), tracks(UID,PID), delete(json(TIDs)), json(_)).
Daniel@0 310
Daniel@0 311 % DCG for building paths as a list of atoms.
Daniel@0 312 playlist(UID,PID) --> playlists(UID), [PID].
Daniel@0 313 playlists(UID) --> [users,UID,playlists].
Daniel@0 314 tracks(UID,PID) --> playlist(UID,PID), [tracks].
Daniel@0 315
Daniel@0 316 spotify_user(App,Me) :- get_state(user(App),Me).
Daniel@0 317
Daniel@0 318 spotify_search(App,Type,Term,Item) :-
Daniel@0 319 spotify(App,search(Type,Term,Response)),
Daniel@0 320 type_field(Type,Field),
Daniel@0 321 get_dict(Field,Response,Results),
Daniel@0 322 get_dict(items,Results,Items),
Daniel@0 323 member(Item,Items).
Daniel@0 324
Daniel@0 325 type_field(artist,artists).
Daniel@0 326 type_field(track,tracks).
Daniel@0 327 type_field(album,albums).
Daniel@0 328
Daniel@0 329 user:portray(Dict) :-
Daniel@0 330 is_dict(Dict),
Daniel@0 331 get_dict(uri,Dict,URI),
Daniel@0 332 ( get_dict(name,Dict,Name)
Daniel@0 333 -> format("<~s|~s>",[URI,Name])
Daniel@0 334 ; format("<~s>",[URI])
Daniel@0 335 ).
Daniel@0 336
Daniel@0 337 prolog:message(directive(Pred)) --> ["~w can only be used as a directive."-[Pred]].
Daniel@0 338 prolog:message(spotify:refreshing_token) --> ["Refreshing Spotify access token..."].
Daniel@0 339 prolog:message(spotify:bad_redirect(R)) --> ["Malformed callback URI '~w'"-[R]].
Daniel@0 340 prolog:message(spotify:missing_app_facet(P)) --> ["spotify_app declaration missing property ~w"-[P]].
Daniel@0 341 prolog:message(spotify:no_matching_state) --> ["Authorisation synchronisation broken"].
Daniel@0 342 prolog:message(spotify:no_code_received) --> ["No authorisation code received"].
Daniel@0 343 prolog:message(spotify:auth_waiting(App)) --> ["Waiting for authorisation of ~w..."-[App]].
Daniel@0 344 prolog:message(spotify:auth_complete(App)) --> ["Authorisation of ~w complete."-[App]].
Daniel@0 345 prolog:message(spotify:auth_opening_browser(App,Port,_)) -->
Daniel@0 346 ["Authorising Spotify app ~w. "-[App]],
Daniel@0 347 ["Your browser should open automatically. If not, please open it manually and"],
Daniel@0 348 [" navigate to < http://localhost:~w/authorise >"-[Port]].