diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cpack/dml/lib/spotify/spotify.pl	Tue Feb 09 21:05:06 2016 +0100
@@ -0,0 +1,348 @@
+/* Part of DML (Digital Music Laboratory)
+	Copyright 2014-2015 Samer Abdallah, University of London
+	 
+	This program is free software; you can redistribute it and/or
+	modify it under the terms of the GNU General Public License
+	as published by the Free Software Foundation; either version 2
+	of the License, or (at your option) any later version.
+
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU General Public License for more details.
+
+	You should have received a copy of the GNU General Public
+	License along with this library; if not, write to the Free Software
+	Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+:- module(spotify, 
+   [	spotify_authorise/1
+   ,  spotify_app/2
+   ,  spotify/2 
+   ,  spotify_user/2
+   ,  spotify_search/4
+   ,  spotify_player//2
+   ,  spotify_player_url/3
+   ]).
+
+/** <module> Interface to Spotify Web API
+
+This module provides access to the Spotify web API.
+It uses dicts, but does not require the .field syntax to be enabled.
+
+https://developer.spotify.com/web-api/
+
+ */
+
+:- use_module(library(http/http_dispatch)).
+:- use_module(library(http/thread_httpd)).
+:- use_module(library(http/http_parameters)).
+:- use_module(library(http/http_ssl_plugin)).
+:- use_module(library(http/html_write)).
+:- use_module(library(base64)).
+:- use_module(library(dcg_core)).
+:- use_module(library(webby)).
+:- use_module(library(insist)).
+:- use_module(library(state)).
+
+:- multifile app_facet/2.
+:- dynamic state/2.
+:- set_prolog_flag(double_quotes,string).
+
+:- http_handler(root(authorise), authorise, []).
+
+%% spotify_app(+App:ground, +Spec:list) is det.
+%
+%  Declares App to be a registered Spotify application. Spec must contain contain
+%  the following elements containing information obtained during the application
+%  registration process. They can be atoms or strings.
+%     *  id(ClientID:text)
+%     *  secret(Secret:text)        
+%     *  redirect(RedirectURL:text)
+%     *  scope(Scopes:text)
+%  This can only be used as a directive, as the expansion process included declaring
+%  an HTTP handler using http_handler/3.%
+spotify_app(_,_) :- throw(directive(spotify_app/2)).
+
+user:term_expansion((:- spotify_app(App,Props)), Clauses) :- !,
+   Info=[id(_),secret(_),redirect(Redirect),scope(_)],
+   maplist(spotify:option_inv(Props),Info),
+   insist(url:parse_url(Redirect,Parts),bad_redirect(Redirect)),
+   member(path(Path),Parts),
+   seqmap(spotify:app_clause(App),Info,Clauses,[(:-http_handler(Path, spotify:callback(App), []))]).
+
+option_inv(Props,Prop) :- insist(option(Prop,Props),missing_app_facet(Prop)).
+app_clause(App,Prop) --> [spotify:app_facet(App,Prop)].
+
+app_port(App,Port) :- 
+   app_facet(App,redirect(Redirect)),
+   parse_url(Redirect,Parts), 
+   member(port(Port),Parts).
+
+
+%% spotify_authorise(+App) is det.
+%
+%  Get authorisation for current app from a Spotify user.
+%  This requires a working www_open_url to initiate an interaction with
+%  the user to handle the Spotify login process. If this doesn't
+%  work, consider changing the 'browser' Prolog flag. For example,
+%  on Mac OS X, add the following to your ~/.plrc
+%  ==
+%  :- set_prolog_flag(browser,open).
+%  ==
+%  After logging in (if necessary) and authorising the app, the browser
+%  should show a page confirming that the process succeeded and giving information
+%  about the current user. This predicate (spotify_authorise/0) will wait
+%  until the confirmation page has been shown. It may hang if something
+%  goes wrong.
+spotify_authorise(App) :- 
+   get_time(Time), variant_sha1(App-Time,Hash),
+   login_url(App,Hash,URL), 
+   app_port(App,Port),
+   with_message_queue(spotify,[max_size(5)],  
+      with_http_server(Port, 
+         (  thread_send_message(spotify,login(App,Hash,URL)), 
+            print_message(information,spotify:auth_opening_browser(App,Port,URL)),
+            www_open_url(URL),
+            print_message(information,spotify:auth_waiting(App)),
+            thread_get_message(spotify,cont(App,Hash,RC),[timeout(300)]),
+            (RC=error(Ex) -> throw(Ex); true),
+            print_message(information,spotify:auth_complete(App))
+         ))).
+
+
+with_http_server(Port,Goal) :-
+   setup_call_cleanup(
+      http_server(http_dispatch, [port(Port)]), Goal,
+      http_stop_server(Port,[])).
+
+with_message_queue(Alias,Opts,Goal) :-
+   setup_call_cleanup(
+      message_queue_create(_,[alias(Alias)|Opts]), Goal,
+      message_queue_destroy(Alias)).
+
+%% login_url(+App,+Hash,-URL) is det.
+login_url(App,Hash,URL) :-
+   maplist(app_facet(App),[id(ID),redirect(Redirect),scope(Scope)]),
+   Params=[ response_type=code,client_id=ID,scope=Scope, redirect_uri=Redirect, state=Hash ],
+   parse_url(URL,[ protocol(https), host('accounts.spotify.com'), path('/authorize'), search(Params)]).
+
+
+%% authorise(Request) is det.
+%
+%  Authorisation process via a web interface.
+authorise(Request) :- 
+   (  thread_peek_message(spotify,login(_,_,URL))
+   -> http_redirect(see_other,URL,Request)
+   ;  reply_html_page(default, [title("SWI Prolog Spotify client")],
+          center(p("Nothing to see here. Move on.")))).
+         
+
+%% callback(+App,+Request) is det.
+%
+%  Handle the callback from Spotify as part of the authorisation process.
+%  This handler must be registered with library(http_dispatch) associated
+%  with 'redirect' URL property of the current app.
+callback(App,Request) :-
+   http_parameters(Request,[ code(Code,   [string, optional(true)])
+                           , error(Error, [string, optional(true)])
+                           , error_description(ErrorDesc, [string, optional(true)])
+                           , state(Hash, [atom, optional(false)]) ]), 
+   debug(spotify,"Got callback (~w)",[Hash]),
+   insist(thread_get_message(spotify,login(App,Hash,_),[timeout(0)]),no_matching_state),
+
+   catch(
+      (  insist(var(Error), auth_error(Error,ErrorDesc)),
+         insist(nonvar(Code),no_code_received),
+         maplist(app_facet(App),[id(ID),secret(Secret),redirect(Redirect)]),
+         get_authorisation([ grant_type='authorization_code'
+                           , code=Code, redirect_uri=Redirect 
+                           , client_id=ID, client_secret=Secret
+                           ], [], Reply),
+         debug(spotify,"Received access and refresh tokens.",[]),
+         debug(spotify,"Posting success to login queue ~w...",[Hash]),
+         Status=ok(Reply)
+      ), Ex, Status=error(Ex)),
+   handle_auth_reply(Status,App,Hash).
+
+handle_auth_reply(ok(Reply),App,Hash) :-
+   set_tokens(App,Reply),
+   debug(spotify,"Posting success to login queue ~w...",[Hash]),
+   thread_send_message(spotify,cont(App,Hash,ok)),
+   spotify(App,me(Me)), is_dict(Me,user),
+   set_state(user(App),Me),
+   reply_html_page(default,
+      [title("SWI-Prolog Spotify Client > Login ok")],
+      [  \html_post(head,style(
+            [  "table.json table.json { border: thin solid gray }"
+            ,  "table.json { border-collapse: collapse }"
+            ,  "table.json td:first-child { font-weight: bold; text-align: right; padding-right:0.5em }"
+            ,  "table.json td { vertical-align:top; padding-left:0.5em }"
+            ,  "body { margin:2em; }"
+            ]))
+      ,  center(div([h3("SWI Prolog library app '~w' authorised as user"-App), \json(Me) ]))
+      ]).
+
+handle_auth_reply(error(Ex),App,Hash) :-
+   debug(spotify,"Posting error to login queue ~w...",[Hash]),
+   thread_send_message(spotify,cont(App,Hash,error(Ex))),
+   (  Ex=http_bad_status(_,Doc), phrase("<!DOCTYPE html>",Doc,_)
+   -> format("Content-type: text/html; charset=UTF-8~n~n~s",[Doc])
+   ;  throw(Ex)
+   ).
+ 
+%% refresh is det.
+%  Refresh the access token for the current app.
+refresh(App) :-
+   app_facet(App,id(ID)),
+   app_facet(App,secret(Secret)),
+   get_state(refresh_token(App),Token),
+   format(codes(IDCodes),"~w:~w", [ID,Secret]),
+   phrase(("Basic ",base64(IDCodes)),AuthCodes),
+   string_codes(Auth,AuthCodes),
+   % debug(spotify,"Refreshing access tokens...",[]),
+   get_authorisation([grant_type=refresh_token, refresh_token=Token],
+                     [request_header('Authorization'=Auth)], Reply),
+   debug(spotify,"Received new access tokens.",[]),
+   set_tokens(App,Reply).
+
+%% set_tokens(+App,+Dict) is det.
+%  Updates the record of the current access and refresh tokens,
+%  and their new expiry time.
+set_tokens(App,Dict) :-
+   _{expires_in:Expiry,access_token:Access} :< Dict,
+   get_time(Now), ExpirationTime is Now+Expiry,
+   set_state(access_token(App),Access-ExpirationTime),
+   (  _{refresh_token:Refresh} :< Dict
+   -> set_state(refresh_token(App),Refresh)
+   ;  true
+   ).
+
+%% usable_token(+App,-Token) is det.
+%  Gets a usable access token, refreshing if the current one expired.
+usable_token(App,Token) :-
+   get_state(access_token(App),Token0-ExpiryDate),
+   get_time(Now),
+   (  ExpiryDate>Now -> Token=Token0
+   ;  refresh(App), usable_token(App,Token)
+   ).
+
+%% spotify_player(+URI:atom,+Opts:options) is det.
+%  HTML component for showing the Spotify web widget.
+spotify_player_url(track(URI),Opts,URL) :-
+   option(theme(Th),Opts,white), must_be(oneof([white,black]),Th),
+   option(view(Vw),Opts,list), must_be(oneof([list,coverart]),Vw),
+   parse_url(URL, [ protocol(https),host('embed.spotify.com'),path(/)
+                  , search([uri=URI, theme=Th, view=Vw])  ]).
+
+spotify_player_url(tracks(Title,URIs),Opts,URL) :-
+   option(theme(Th),Opts,white), must_be(oneof([white,black]),Th),
+   option(view(Vw),Opts,list), must_be(oneof([list,coverart]),Vw),
+   maplist(string_concat('spotify:track:'),IDs,URIs),
+   atomics_to_string(IDs,',',IDList),
+   atomics_to_string([spotify,trackset,Title,IDList],':',URI),
+   parse_url(URL, [ protocol(https),host('embed.spotify.com'),path(/)
+                  , search([uri=URI, theme=Th, view=Vw])  ]).
+
+spotify_player(Spec,Opts) -->
+   { spotify_player_url(Spec,Opts,URL),
+     option(width(W),Opts,300), must_be(between(250,640),W),
+     option(height(H),Opts,300), must_be(between(80,720),H)
+   },
+   html(iframe([ src=URL,width=W,height=H,frameborder=0,allowtransparency=true],[])).
+
+% multiple tracks:
+% uri='spotify:trackset:PREFEREDTITLE:5Z7ygHQo02SUrFmcgpwsKW,1x6ACsKV4UdWS2FMuPFUiT'
+json(Dict) -->
+   {is_dict(Dict), !, dict_pairs(Dict,_,Pairs)}, 
+   html(table(class=json, 
+        [colgroup([col(style="border-right:thin solid black"),col([])]), \seqmap(pair_row,Pairs)])).
+json(List) --> {is_list(List)}, !, html(ol(\seqmap(val_li,List))).
+json(Val) --> html("~w"-Val).
+val_li(Val) --> html(li(\json(Val))).
+pair_row(Name-Val) --> html(tr( [ td(Name), td(\json(Val))])).  
+
+%% spotify(+App,+Request) is nondet.
+%
+%  This is the main predicate for making calls to the Spotify web API. The nature of
+%  the call, its parameters, and its results are encoded in the term Request.
+%  See request/4 for information about what requests are recognised.
+%  This predicate will formulate the call URL, refresh the access token if necessary,
+%  make the call, and read the results.
+spotify(App,Req) :-
+   request(Req,PathPhrase,Method,Reader), !,
+   phrase(PathPhrase,PathParts),
+   parts_path([v1|PathParts],Path),
+   usable_token(App,Token),
+   string_concat("Bearer ", Token, Auth),
+   spotify(Method,Reader,'api.spotify.com',Path,
+            [ request_header('Authorization'=Auth) ]).
+
+% just for internal use
+get_authorisation(Params,Opts,Reply) :- 
+   spotify(post(form(Params)), json(Reply), 'accounts.spotify.com', '/api/token', Opts).
+
+% All web calls come through here eventually.
+spotify(Method,Reader,Host,Path,Opts) :-
+   restcall(Method, Reader,
+            [ protocol(https), host(Host), path(Path) ],
+            [ request_header('Accept'='application/json')
+            , cert_verify_hook(spotify:verify)
+            | Opts ]).
+
+verify(_, Problem, _All, _First, Error) :-
+   debug(ssl,"Accepting problem certificate (~w)\n~w\n",[Error,Problem]).
+
+%% request(-Req:spotify_request, -PathPhrase:phrase(atom), -Method:web_method, -Reader:web_reader) is nondet.
+%
+%  Database of mappings from requests to end-points.
+request( me(Me),                       [me],              get([]),                  json(Me)).
+request( track(TID,T),                 [tracks,TID],      get([]),                  json(T)).
+request( playlists(UID,Ls),            playlists(UID),    get([]),                  json(Ls)).
+request( playlist(UID,PID,L),          playlist(UID,PID), get([]),                  json(L)).
+request( playlist_tracks(UID,PID,Ts),  tracks(UID,PID),   get([]),                  json(Ts)).
+request( search(Type,Term,L),          [search],          get([(type)=Type,q=Term]),  json(L)).
+request( create_playlist(UID,Name,PL), playlists(UID),    post(json(_{name:Name})), json(PL)).
+request( add_tracks(UID,PID,URIs),     tracks(UID,PID),   post(json(URIs)), nil).
+request( set_tracks(UID,PID,URIs),     tracks(UID,PID),   put(json(_{uris:URIs})),  nil).
+request( del_tracks(UID,PID,TIDs),     tracks(UID,PID),   delete(json(TIDs)),       json(_)).
+
+% DCG for building paths as a list of atoms.
+playlist(UID,PID) --> playlists(UID), [PID].
+playlists(UID) --> [users,UID,playlists].
+tracks(UID,PID) --> playlist(UID,PID), [tracks].
+
+spotify_user(App,Me) :- get_state(user(App),Me).
+
+spotify_search(App,Type,Term,Item) :-
+   spotify(App,search(Type,Term,Response)),
+   type_field(Type,Field),
+   get_dict(Field,Response,Results),
+   get_dict(items,Results,Items),
+   member(Item,Items).
+
+type_field(artist,artists).
+type_field(track,tracks).
+type_field(album,albums).
+
+user:portray(Dict) :- 
+   is_dict(Dict),  
+   get_dict(uri,Dict,URI),
+   (  get_dict(name,Dict,Name) 
+   -> format("<~s|~s>",[URI,Name])
+   ;  format("<~s>",[URI])
+   ).
+
+prolog:message(directive(Pred)) --> ["~w can only be used as a directive."-[Pred]].
+prolog:message(spotify:refreshing_token) --> ["Refreshing Spotify access token..."].
+prolog:message(spotify:bad_redirect(R)) --> ["Malformed callback URI '~w'"-[R]]. 
+prolog:message(spotify:missing_app_facet(P)) --> ["spotify_app declaration missing property ~w"-[P]].
+prolog:message(spotify:no_matching_state) --> ["Authorisation synchronisation broken"].
+prolog:message(spotify:no_code_received) --> ["No authorisation code received"].
+prolog:message(spotify:auth_waiting(App)) --> ["Waiting for authorisation of ~w..."-[App]].
+prolog:message(spotify:auth_complete(App)) --> ["Authorisation of ~w complete."-[App]].
+prolog:message(spotify:auth_opening_browser(App,Port,_)) -->
+   ["Authorising Spotify app ~w. "-[App]],
+   ["Your browser should open automatically. If not, please open it manually and"],
+   [" navigate to < http://localhost:~w/authorise >"-[Port]].