1<?php
2require_once 'Soundcloud/Exception.php';
3require_once 'Soundcloud/Version.php';
4
5/**
6 * SoundCloud API wrapper with support for authentication using OAuth 2
7 *
8 * @category  Services
9 * @package   Services_Soundcloud
10 * @author    Anton Lindqvist <anton@qvister.se>
11 * @copyright 2010 Anton Lindqvist <anton@qvister.se>
12 * @license   http://www.opensource.org/licenses/mit-license.php MIT
13 * @link      http://github.com/mptre/php-soundcloud
14 */
15class Services_Soundcloud
16{
17
18    /**
19     * Custom cURL option
20     *
21     * @var integer
22     *
23     * @access public
24     */
25    const CURLOPT_OAUTH_TOKEN = 173;
26
27    /**
28     * Access token returned by the service provider after a successful authentication
29     *
30     * @var string
31     *
32     * @access private
33     */
34    private $_accessToken;
35
36    /**
37     * Version of the API to use
38     *
39     * @var integer
40     *
41     * @access private
42     * @static
43     */
44    private static $_apiVersion = 1;
45
46    /**
47     * Supported audio MIME types
48     *
49     * @var array
50     *
51     * @access private
52     * @static
53     */
54    private static $_audioMimeTypes = array(
55        'aac' => 'video/mp4',
56        'aiff' => 'audio/x-aiff',
57        'flac' => 'audio/flac',
58        'mp3' => 'audio/mpeg',
59        'ogg' => 'audio/ogg',
60        'wav' => 'audio/x-wav'
61    );
62
63    /**
64     * OAuth client id
65     *
66     * @var string
67     *
68     * @access private
69     */
70    private $_clientId;
71
72    /**
73     * OAuth client secret
74     *
75     * @var string
76     *
77     * @access private
78     */
79    private $_clientSecret;
80
81    /**
82     * Default cURL options
83     *
84     * @var array
85     *
86     * @access private
87     * @static
88     */
89     private static $_curlDefaultOptions = array(
90         CURLOPT_HEADER => true,
91         CURLOPT_RETURNTRANSFER => true,
92         CURLOPT_USERAGENT => ''
93     );
94
95    /**
96     * cURL options
97     *
98     * @var array
99     *
100     * @access private
101     */
102    private $_curlOptions;
103
104    /**
105     * Development mode
106     *
107     * @var boolean
108     *
109     * @access private
110     */
111     private $_development;
112
113    /**
114     * Available API domains
115     *
116     * @var array
117     *
118     * @access private
119     * @static
120     */
121    private static $_domains = array(
122        'development' => 'sandbox-soundcloud.com',
123        'production' => 'soundcloud.com'
124    );
125
126    /**
127     * HTTP response body from the last request
128     *
129     * @var string
130     *
131     * @access private
132     */
133    private $_lastHttpResponseBody;
134
135    /**
136     * HTTP response code from the last request
137     *
138     * @var integer
139     *
140     * @access private
141     */
142    private $_lastHttpResponseCode;
143
144    /**
145     * HTTP response headers from last request
146     *
147     * @var array
148     *
149     * @access private
150     */
151    private $_lastHttpResponseHeaders;
152
153    /**
154     * OAuth paths
155     *
156     * @var array
157     *
158     * @access private
159     * @static
160     */
161    private static $_paths = array(
162        'authorize' => 'connect',
163        'access_token' => 'oauth2/token',
164    );
165
166    /**
167     * OAuth redirect URI
168     *
169     * @var string
170     *
171     * @access private
172     */
173    private $_redirectUri;
174
175    /**
176     * API response format MIME type
177     *
178     * @var string
179     *
180     * @access private
181     */
182    private $_requestFormat;
183
184    /**
185     * Available response formats
186     *
187     * @var array
188     *
189     * @access private
190     * @static
191     */
192    private static $_responseFormats = array(
193        '*' => '*/*',
194        'json' => 'application/json',
195        'xml' => 'application/xml'
196    );
197
198    /**
199     * HTTP user agent
200     *
201     * @var string
202     *
203     * @access private
204     * @static
205     */
206    private static $_userAgent = 'PHP-SoundCloud';
207
208    /**
209     * Class constructor
210     *
211     * @param string  $clientId     OAuth client id
212     * @param string  $clientSecret OAuth client secret
213     * @param string  $redirectUri  OAuth redirect URI
214     * @param boolean $development  Sandbox mode
215     *
216     * @return void
217     * @throws Services_Soundcloud_Missing_Client_Id_Exception
218     *
219     * @access public
220     */
221    function __construct($clientId, $clientSecret, $redirectUri = null, $development = false)
222    {
223        if (empty($clientId)) {
224            throw new Services_Soundcloud_Missing_Client_Id_Exception();
225        }
226
227        $this->_clientId = $clientId;
228        $this->_clientSecret = $clientSecret;
229        $this->_redirectUri = $redirectUri;
230        $this->_development = $development;
231        $this->_responseFormat = self::$_responseFormats['json'];
232        $this->_curlOptions = self::$_curlDefaultOptions;
233        $this->_curlOptions[CURLOPT_USERAGENT] .= $this->_getUserAgent();
234    }
235
236    /**
237     * Get authorization URL
238     *
239     * @param array $params Optional query string parameters
240     *
241     * @return string
242     *
243     * @access public
244     * @see Soundcloud::_buildUrl()
245     */
246    function getAuthorizeUrl($params = array())
247    {
248        $defaultParams = array(
249            'client_id' => $this->_clientId,
250            'redirect_uri' => $this->_redirectUri,
251            'response_type' => 'code'
252        );
253        $params = array_merge($defaultParams, $params);
254
255        return $this->_buildUrl(self::$_paths['authorize'], $params, false);
256    }
257
258    /**
259     * Get access token URL
260     *
261     * @param array $params Optional query string parameters
262     *
263     * @return string
264     *
265     * @access public
266     * @see Soundcloud::_buildUrl()
267     */
268    function getAccessTokenUrl($params = array())
269    {
270        return $this->_buildUrl(self::$_paths['access_token'], $params, false);
271    }
272
273    /**
274     * Retrieve access token through credentials flow
275     *
276     * @param string $username Username
277     * @param string $password Password
278     *
279     * @return mixed
280     *
281     * @access public
282     */
283    function credentialsFlow($username, $password)
284    {
285        $postData = array(
286            'client_id' => $this->_clientId,
287            'client_secret' => $this->_clientSecret,
288            'username' => $username,
289            'password' => $password,
290            'grant_type' => 'password'
291        );
292
293        $options = array(CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData);
294        $response = json_decode(
295            $this->_request($this->getAccessTokenUrl(), $options),
296            true
297        );
298
299        if (array_key_exists('access_token', $response)) {
300            $this->_accessToken = $response['access_token'];
301
302            return $response;
303        } else {
304            return false;
305        }
306    }
307
308    /**
309     * Retrieve access token
310     *
311     * @param string $code        Optional OAuth code returned from the service provider
312     * @param array  $postData    Optional post data
313     * @param array  $curlOptions Optional cURL options
314     *
315     * @return mixed
316     *
317     * @access public
318     * @see Soundcloud::_getAccessToken()
319     */
320    function accessToken($code = null, $postData = array(), $curlOptions = array())
321    {
322        $defaultPostData = array(
323            'code' => $code,
324            'client_id' => $this->_clientId,
325            'client_secret' => $this->_clientSecret,
326            'redirect_uri' => $this->_redirectUri,
327            'grant_type' => 'authorization_code'
328        );
329        $postData = array_filter(array_merge($defaultPostData, $postData));
330
331        return $this->_getAccessToken($postData, $curlOptions);
332    }
333
334    /**
335     * Refresh access token
336     *
337     * @param string $refreshToken The token to refresh
338     * @param array  $postData     Optional post data
339     * @param array  $curlOptions  Optional cURL options
340     *
341     * @return mixed
342     * @see Soundcloud::_getAccessToken()
343     *
344     * @access public
345     */
346    function accessTokenRefresh($refreshToken, $postData = array(), $curlOptions = array())
347    {
348        $defaultPostData = array(
349            'refresh_token' => $refreshToken,
350            'client_id' => $this->_clientId,
351            'client_secret' => $this->_clientSecret,
352            'redirect_uri' => $this->_redirectUri,
353            'grant_type' => 'refresh_token'
354        );
355        $postData = array_merge($defaultPostData, $postData);
356
357        return $this->_getAccessToken($postData, $curlOptions);
358    }
359
360    /**
361     * Get access token
362     *
363     * @return mixed
364     *
365     * @access public
366     */
367    function getAccessToken()
368    {
369        return $this->_accessToken;
370    }
371
372    /**
373     * Get API version
374     *
375     * @return integer
376     *
377     * @access public
378     */
379    function getApiVersion()
380    {
381        return self::$_apiVersion;
382    }
383
384    /**
385     * Get the corresponding MIME type for a given file extension
386     *
387     * @param string $extension Given extension
388     *
389     * @return string
390     * @throws Services_Soundcloud_Unsupported_Audio_Format_Exception
391     *
392     * @access public
393     */
394    function getAudioMimeType($extension)
395    {
396        if (array_key_exists($extension, self::$_audioMimeTypes)) {
397            return self::$_audioMimeTypes[$extension];
398        } else {
399            throw new Services_Soundcloud_Unsupported_Audio_Format_Exception();
400        }
401    }
402
403    /**
404     * Get cURL options
405     *
406     * @param string $key Optional options key
407     *
408     * @return mixed
409     *
410     * @access public
411     */
412    function getCurlOptions($key = null)
413    {
414        if ($key) {
415            return (array_key_exists($key, $this->_curlOptions))
416                ? $this->_curlOptions[$key]
417                : false;
418        } else {
419            return $this->_curlOptions;
420        }
421    }
422
423    /**
424     * Get development mode
425     *
426     * @return boolean
427     *
428     * @access public
429     */
430    function getDevelopment()
431    {
432        return $this->_development;
433    }
434
435    /**
436     * Get HTTP response header
437     *
438     * @param string $header Name of the header
439     *
440     * @return mixed
441     *
442     * @access public
443     */
444    function getHttpHeader($header)
445    {
446        if (is_array($this->_lastHttpResponseHeaders)
447            && array_key_exists($header, $this->_lastHttpResponseHeaders)
448        ) {
449            return $this->_lastHttpResponseHeaders[$header];
450        } else {
451            return false;
452        }
453    }
454
455    /**
456     * Get redirect URI
457     *
458     * @return string
459     *
460     * @access public
461     */
462    function getRedirectUri()
463    {
464        return $this->_redirectUri;
465    }
466
467    /**
468     * Get response format
469     *
470     * @return string
471     *
472     * @access public
473     */
474    function getResponseFormat()
475    {
476        return $this->_responseFormat;
477    }
478
479    /**
480     * Set access token
481     *
482     * @param string $accessToken Access token
483     *
484     * @return object
485     *
486     * @access public
487     */
488    function setAccessToken($accessToken)
489    {
490        $this->_accessToken = $accessToken;
491
492        return $this;
493    }
494
495    /**
496     * Set cURL options
497     *
498     * The method accepts arguments in two ways.
499     *
500     * You could pass two arguments when adding a single option.
501     * <code>
502     * $soundcloud->setCurlOptions(CURLOPT_SSL_VERIFYHOST, 0);
503     * </code>
504     *
505     * You could also pass an associative array when adding multiple options.
506     * <code>
507     * $soundcloud->setCurlOptions(array(
508     *     CURLOPT_SSL_VERIFYHOST => 0,
509     *    CURLOPT_SSL_VERIFYPEER => 0
510     * ));
511     * </code>
512     *
513     * @return object
514     *
515     * @access public
516     */
517    function setCurlOptions()
518    {
519        $args = func_get_args();
520        $options = (is_array($args[0]))
521            ? $args[0]
522            : array($args[0] => $args[1]);
523
524        foreach ($options as $key => $val) {
525            $this->_curlOptions[$key] = $val;
526        }
527
528        return $this;
529    }
530
531    /**
532     * Set redirect URI
533     *
534     * @param string $redirectUri Redirect URI
535     *
536     * @return object
537     *
538     * @access public
539     */
540    function setRedirectUri($redirectUri)
541    {
542        $this->_redirectUri = $redirectUri;
543
544        return $this;
545    }
546
547    /**
548     * Set response format
549     *
550     * @param string $format Response format, could either be XML or JSON
551     *
552     * @return object
553     * @throws Services_Soundcloud_Unsupported_Response_Format_Exception
554     *
555     * @access public
556     */
557    function setResponseFormat($format)
558    {
559        if (array_key_exists($format, self::$_responseFormats)) {
560            $this->_responseFormat = self::$_responseFormats[$format];
561        } else {
562            throw new Services_Soundcloud_Unsupported_Response_Format_Exception();
563        }
564
565        return $this;
566    }
567
568    /**
569     * Set development mode
570     *
571     * @param boolean $development Development mode
572     *
573     * @return object
574     *
575     * @access public
576     */
577    function setDevelopment($development)
578    {
579        $this->_development = $development;
580
581        return $this;
582    }
583
584    /**
585     * Send a GET HTTP request
586     *
587     * @param string $path        Request path
588     * @param array  $params      Optional query string parameters
589     * @param array  $curlOptions Optional cURL options
590     *
591     * @return mixed
592     *
593     * @access public
594     * @see Soundcloud::_request()
595     */
596    function get($path, $params = array(), $curlOptions = array())
597    {
598        $url = $this->_buildUrl($path, $params);
599
600        return $this->_request($url, $curlOptions);
601    }
602
603    /**
604     * Send a POST HTTP request
605     *
606     * @param string $path        Request path
607     * @param array  $postData    Optional post data
608     * @param array  $curlOptions Optional cURL options
609     *
610     * @return mixed
611     *
612     * @access public
613     * @see Soundcloud::_request()
614     */
615    function post($path, $postData = array(), $curlOptions = array())
616    {
617        $url = $this->_buildUrl($path);
618        $options = array(CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData);
619        $options += $curlOptions;
620
621        return $this->_request($url, $options);
622    }
623
624    /**
625     * Send a PUT HTTP request
626     *
627     * @param string $path        Request path
628     * @param array  $postData    Optional post data
629     * @param array  $curlOptions Optional cURL options
630     *
631     * @return mixed
632     *
633     * @access public
634     * @see Soundcloud::_request()
635     */
636    function put($path, $postData, $curlOptions = array())
637    {
638        $url = $this->_buildUrl($path);
639        $options = array(
640            CURLOPT_CUSTOMREQUEST => 'PUT',
641            CURLOPT_POSTFIELDS => $postData
642        );
643        $options += $curlOptions;
644
645        return $this->_request($url, $options);
646    }
647
648    /**
649     * Send a DELETE HTTP request
650     *
651     * @param string $path        Request path
652     * @param array  $params      Optional query string parameters
653     * @param array  $curlOptions Optional cURL options
654     *
655     * @return mixed
656     *
657     * @access public
658     * @see Soundcloud::_request()
659     */
660    function delete($path, $params = array(), $curlOptions = array())
661    {
662        $url = $this->_buildUrl($path, $params);
663        $options = array(CURLOPT_CUSTOMREQUEST => 'DELETE');
664        $options += $curlOptions;
665
666        return $this->_request($url, $options);
667    }
668
669    /**
670     * Download track
671     *
672     * @param integer $trackId     Track id to download
673     * @param array   $params      Optional query string parameters
674     * @param array   $curlOptions Optional cURL options
675     *
676     * @return mixed
677     *
678     * @access public
679     * @see Soundcloud::_request()
680     */
681    function download($trackId, $params = array(), $curlOptions = array())
682    {
683        $lastResponseFormat = array_pop(explode('/', $this->getResponseFormat()));
684        $defaultParams = array('oauth_token' => $this->getAccessToken());
685        $defaultCurlOptions = array(
686            CURLOPT_FOLLOWLOCATION => true,
687            self::CURLOPT_OAUTH_TOKEN => false
688        );
689        $url = $this->_buildUrl(
690            'tracks/' . $trackId . '/download',
691            array_merge($defaultParams, $params)
692        );
693        $options = $defaultCurlOptions + $curlOptions;
694
695        $this->setResponseFormat('*');
696
697        $response = $this->_request($url, $options);
698
699        // rollback to the previously defined response format.
700        $this->setResponseFormat($lastResponseFormat);
701
702        return $response;
703    }
704
705    /**
706     * Update a existing playlist
707     *
708     * @param integer $playlistId       The playlist id
709     * @param array   $trackIds         Tracks to add to the playlist
710     * @param array   $optionalPostData Optional playlist fields to update
711     *
712     * @return mixed
713     *
714     * @access public
715     * @see Soundcloud::_request()
716     */
717    public function updatePlaylist($playlistId, $trackIds, $optionalPostData = null)
718    {
719        $url = $this->_buildUrl('playlists/' . $playlistId);
720        $postData = array();
721
722        foreach ($trackIds as $trackId) {
723            array_push($postData, 'playlist[tracks][][id]=' . $trackId);
724        }
725
726        if (is_array($optionalPostData)) {
727            foreach ($optionalPostData as $key => $val) {
728                array_push($postData, 'playlist[' . $key . ']=' . $val);
729            }
730        }
731
732        $postData = implode('&', $postData);
733        $curlOptions = array(
734            CURLOPT_CUSTOMREQUEST => 'PUT',
735            CURLOPT_HTTPHEADER => array('Content-Length' => strlen($postData)),
736            CURLOPT_POSTFIELDS => $postData
737        );
738
739        return $this->_request($url, $curlOptions);
740    }
741
742    /**
743     * Construct default HTTP request headers
744     *
745     * @param boolean $includeAccessToken Include access token
746     *
747     * @return array $headers
748     *
749     * @access protected
750     */
751    protected function _buildDefaultHeaders($includeAccessToken = true)
752    {
753        $headers = array();
754
755        if ($this->_responseFormat) {
756            array_push($headers, 'Accept: ' . $this->_responseFormat);
757        }
758
759        if ($includeAccessToken && $this->_accessToken) {
760            array_push($headers, 'Authorization: OAuth ' . $this->_accessToken);
761        }
762
763        return $headers;
764    }
765
766    /**
767     * Construct a URL
768     *
769     * @param string  $path           Relative or absolute URI
770     * @param array   $params         Optional query string parameters
771     * @param boolean $includeVersion Include API version
772     *
773     * @return string $url
774     *
775     * @access protected
776     */
777    protected function _buildUrl($path, $params = array(), $includeVersion = true)
778    {
779        if (!$this->_accessToken) {
780            $params['consumer_key'] = $this->_clientId;
781        }
782
783        if (preg_match('/^https?\:\/\//', $path)) {
784            $url = $path;
785        } else {
786            $url = 'https://';
787            $url .= (!preg_match('/connect/', $path)) ? 'api.' : '';
788            $url .= ($this->_development)
789                ? self::$_domains['development']
790                : self::$_domains['production'];
791            $url .= '/';
792            $url .= ($includeVersion) ? 'v' . self::$_apiVersion . '/' : '';
793            $url .= $path;
794        }
795
796        $url .= (count($params)) ? '?' . http_build_query($params) : '';
797
798        return $url;
799    }
800
801    /**
802     * Retrieve access token
803     *
804     * @param array $postData    Post data
805     * @param array $curlOptions Optional cURL options
806     *
807     * @return mixed
808     *
809     * @access protected
810     */
811    protected function _getAccessToken($postData, $curlOptions = array())
812    {
813        $options = array(CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData);
814        $options += $curlOptions;
815        $response = json_decode(
816            $this->_request($this->getAccessTokenUrl(), $options),
817            true
818        );
819
820        if (array_key_exists('access_token', $response)) {
821            $this->_accessToken = $response['access_token'];
822
823            return $response;
824        } else {
825            return false;
826        }
827    }
828
829    /**
830     * Get HTTP user agent
831     *
832     * @return string
833     *
834     * @access protected
835     */
836    protected function _getUserAgent()
837    {
838        return self::$_userAgent . '/' . new Services_Soundcloud_Version;
839    }
840
841    /**
842     * Parse HTTP headers
843     *
844     * @param string $headers HTTP headers
845     *
846     * @return array $parsedHeaders
847     *
848     * @access protected
849     */
850    protected function _parseHttpHeaders($headers)
851    {
852        $headers = explode("\n", trim($headers));
853        $parsedHeaders = array();
854
855        foreach ($headers as $header) {
856            if (!preg_match('/\:\s/', $header)) {
857                continue;
858            }
859
860            list($key, $val) = explode(': ', $header, 2);
861            $key = str_replace('-', '_', strtolower($key));
862            $val = trim($val);
863
864            $parsedHeaders[$key] = $val;
865        }
866
867        return $parsedHeaders;
868    }
869
870    /**
871     * Validate HTTP response code
872     *
873     * @param integer $code HTTP code
874     *
875     * @return boolean
876     *
877     * @access protected
878     */
879    protected function _validResponseCode($code)
880    {
881        return (bool)preg_match('/^20[0-9]{1}$/', $code);
882    }
883
884    /**
885     * Performs the actual HTTP request using cURL
886     *
887     * @param string $url         Absolute URL to request
888     * @param array  $curlOptions Optional cURL options
889     *
890     * @return mixed
891     * @throws Services_Soundcloud_Invalid_Http_Response_Code_Exception
892     *
893     * @access protected
894     */
895    protected function _request($url, $curlOptions = array())
896    {
897        $ch = curl_init($url);
898        $options = $this->_curlOptions;
899        $options += $curlOptions;
900
901        if (array_key_exists(self::CURLOPT_OAUTH_TOKEN, $options)) {
902            $includeAccessToken = $options[self::CURLOPT_OAUTH_TOKEN];
903            unset($options[self::CURLOPT_OAUTH_TOKEN]);
904        } else {
905            $includeAccessToken = true;
906        }
907
908        if (array_key_exists(CURLOPT_HTTPHEADER, $options)) {
909            $options[CURLOPT_HTTPHEADER] = array_merge(
910                $this->_buildDefaultHeaders(),
911                $curlOptions[CURLOPT_HTTPHEADER]
912            );
913        } else {
914            $options[CURLOPT_HTTPHEADER] = $this->_buildDefaultHeaders(
915                $includeAccessToken
916            );
917        }
918
919        curl_setopt_array($ch, $options);
920
921        $data = curl_exec($ch);
922        $info = curl_getinfo($ch);
923
924        curl_close($ch);
925
926        if (array_key_exists(CURLOPT_HEADER, $options) && $options[CURLOPT_HEADER]) {
927            $this->_lastHttpResponseHeaders = $this->_parseHttpHeaders(
928                substr($data, 0, $info['header_size'])
929            );
930            $this->_lastHttpResponseBody = substr($data, $info['header_size']);
931        } else {
932            $this->_lastHttpResponseHeaders = array();
933            $this->_lastHttpResponseBody = $data;
934        }
935
936        $this->_lastHttpResponseCode = $info['http_code'];
937
938        if ($this->_validResponseCode($this->_lastHttpResponseCode)) {
939            return $this->_lastHttpResponseBody;
940        } else {
941            throw new Services_Soundcloud_Invalid_Http_Response_Code_Exception(
942                null,
943                0,
944                $this->_lastHttpResponseBody,
945                $this->_lastHttpResponseCode
946            );
947        }
948    }
949
950}
951