PageRenderTime 58ms CodeModel.GetById 25ms RepoModel.GetById 0ms app.codeStats 0ms

/SpinPapiClient.inc.php

https://bitbucket.org/Spinitron/spinpapi-php-client
PHP | 579 lines | 274 code | 36 blank | 269 comment | 60 complexity | 4f8999edd0cf6e2a8b5568dd78da8eb0 MD5 | raw file
  1. <?php
  2. /**
  3. * Class file of SpinPapiClient.
  4. *
  5. * PHP version 5.3
  6. *
  7. * Copyright (c) 2011-2013 Spinitron, LLC.
  8. * All rights reserved.
  9. *
  10. * Redistribution and use in source and binary forms, with or without modification,
  11. * are permitted provided that the following conditions are met:
  12. *
  13. * - Redistributions of source code must retain the above copyright notice, this
  14. * list of conditions and the following disclaimer.
  15. *
  16. * - Redistributions in binary form must reproduce the above copyright notice,
  17. * this list of conditions and the following disclaimer in the documentation
  18. * and/or other materials provided with the distribution.
  19. *
  20. * - Neither the name of Spinitron, LLC nor the names of its contributors may be
  21. * used to endorse or promote products derived from this software without
  22. * specific prior written permission.
  23. *
  24. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  25. * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  26. * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
  27. * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
  28. * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
  29. * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
  30. * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
  31. * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
  32. * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
  33. * THE POSSIBILITY OF SUCH DAMAGE.
  34. *
  35. * @category SpinPapi
  36. * @author Tom Worster <tom@spinitron.com>
  37. * @copyright 2011-2013 Spinitron, LLC
  38. * @license BSD 3-Clause License. http://opensource.org/licenses/BSD-3-Clause
  39. * @version 2013-03-04
  40. * @link https://bitbucket.org/Spinitron/spinpapi-php-client
  41. */
  42. /**
  43. * Presents an API to query SpinPapi and cache results.
  44. *
  45. * The SpinPapiClient class provides methods to retrieve data from Spinitron
  46. * using its SpinPapi API.
  47. *
  48. * It implements and provides methods to access an optional data cache that
  49. * uses local file storage. Alternatively the user may provide Memcache
  50. * server(s) and configure an instance to use that cache instead. In either
  51. * case it implements a generic, application-neutral cacheing policy with
  52. * heuristics derived from Spinitron's data semantics and typical Spinitron
  53. * use.
  54. *
  55. * SpinPapi users MUST use a data cache. Users may implment their own data
  56. * cache and use this SpinPapiClient in non-caching mode or they may
  57. * implement their own client and cache. In any case, the client must cache
  58. * SpinPapi data.
  59. *
  60. * The user must set the default timezone to the station's local timezone.
  61. *
  62. * @see SpinPapi API Specification.
  63. * @link http://spinitron.com/user-guide/pdf/SpinPapi-v2.pdf
  64. * @package SpinPapiClient
  65. * @version 2011-09-08
  66. */
  67. class SpinPapiClient {
  68. /**#@+
  69. * File cache expiration time in seconds.
  70. */
  71. const EXP_S = 60;
  72. const EXP_M = 3600;
  73. const EXP_L = 10800;
  74. /**#@-*/
  75. /**
  76. * File cache garbage collector runs at random on average once per
  77. * GC_RATIO calls to SpinPapiClient::query(). Turn this up on a busy
  78. * server. Running the GC once a minute is more than enough.
  79. */
  80. const GC_RATIO = 100;
  81. /**#@+
  82. * SpinPapi service config parameter.
  83. */
  84. public $spHost = 'spinitron.com';
  85. public $spUrl = '/public/spinpapi.php';
  86. public $spVer = '2';
  87. /**#@-*/
  88. /**
  89. * @var string Pretty-printed JSON of an API query result available after an
  90. * API request but not after a cache hit.
  91. */
  92. public $prettyJson;
  93. /**
  94. * The SpinPapi user's authentication secret. Invariant after construction.
  95. *
  96. * @var array
  97. */
  98. private $secret;
  99. /**
  100. * The SpinPapi user's static quary parameters. Invariant after construction.
  101. *
  102. * @var array
  103. */
  104. private $qp;
  105. /**
  106. * The Memcached object, instance the PECL class or null to indicate that
  107. * memcached isn't being used.
  108. *
  109. * @var Memcached
  110. */
  111. private $mc = null;
  112. /**
  113. * The file cache directory path or null to indicate that the file cache
  114. * isn't being used.
  115. *
  116. * @var string
  117. */
  118. private $fcDir = null;
  119. /**
  120. * Hash of recognized SpinPapi methods to their two-letter abbreviations.
  121. *
  122. * @var array
  123. */
  124. private $spMethods;
  125. /**
  126. * Hash of recognized SpinPapi parameter names to their 2-letter abbrev.
  127. *
  128. * @var array
  129. */
  130. private $spParams;
  131. /**
  132. * Flag, true if the client requests errors be logged to PHP error log.
  133. *
  134. * @var bool
  135. */
  136. private $logErrors;
  137. /**
  138. * Create and return a new SpinPapiClinet object.
  139. *
  140. * @param string $userid User's authentication user ID.
  141. * @param string $secret User's authentication secret.
  142. * @param string $station Spinitron station ID.
  143. * @param bool $logErrors Optionally set true to log errors to PHP log.
  144. * @param string $version SpinPapi API version string.
  145. */
  146. public function __construct($userid, $secret, $station, $logErrors = false, $version = '1') {
  147. $this->secret = $secret;
  148. $this->qp = array(
  149. 'papiuser' => $userid,
  150. 'station' => $station,
  151. 'papiversion' => $version,
  152. );
  153. $this->logErrors = $logErrors;
  154. $this->spMethods = array(
  155. 'getSong' => 'sg',
  156. 'getSongs' => 'ss',
  157. 'getCurrentPlaylist' => 'cp',
  158. 'getPlaylistInfo' => 'pi',
  159. 'getPlaylistsInfo' => 'ps',
  160. 'getShowInfo' => 'si',
  161. 'getRegularShowsInfo' => 'rs',
  162. 'getDJ' => 'dj',
  163. 'getDJs' => 'ds',
  164. );
  165. $this->spParams = array(
  166. 'SongID' => 's',
  167. 'PlaylistID' => 'p',
  168. 'ShowID' => 'h',
  169. 'When' => 'w',
  170. 'StartHour' => 'h',
  171. 'UserID' => 'u',
  172. 'Num' => 'n',
  173. 'EndDate' => 'e',
  174. );
  175. }
  176. /**
  177. * Initialize the Memcached client.
  178. *
  179. * If you initialize the Memcached client sucessfully then the file cache
  180. * is disabled and SpinPapiClient::query() will try to use Memcached.
  181. *
  182. * @param array $servers The memcached server(s) to use. Each entry in
  183. * is supposed to be an array containing hostname, port,
  184. * and, optionally, weight of the server.
  185. *
  186. * @see http://www.php.net/manual/en/memcached.addservers.php
  187. * @return bool True means a Memcached object was sucessfully instantiated
  188. * but doesn't imply the servers are working.
  189. */
  190. public function mcInit($servers) {
  191. if (!class_exists('Memcached')) {
  192. return false;
  193. }
  194. $this->mc = new Memcached('my_connection');
  195. if (get_class($this->mc) !== 'Memcached'
  196. || !$this->mc->addServers($servers)
  197. ) {
  198. $this->mc = null;
  199. return false;
  200. }
  201. $this->fcDir = null;
  202. return true;
  203. }
  204. /**
  205. * Initialize the local file system data cache.
  206. *
  207. * You must provide a directory for the exclusive use of this cache. It
  208. * must be writable by whatever process uses SpinPapiClient. The file
  209. * system must support PHP's filemtime() function.
  210. *
  211. * If you initialize the file cache sucessfully then the Memcached client
  212. * is disabled and SpinPapiClient::query() will try to the file cache.
  213. *
  214. * @param string $dir Path of the file cache's directory.
  215. *
  216. * @return bool True if $dir exists and is writable.
  217. */
  218. public function fcInit($dir) {
  219. if (is_dir($dir)
  220. && is_writable($dir)
  221. ) {
  222. $this->fcDir = $dir;
  223. $this->mc = null;
  224. return true;
  225. }
  226. return false;
  227. }
  228. /**
  229. * Get data from SpinPapi.
  230. *
  231. * @see SpinPapi API Specification.
  232. * @link http://spinitron.com/user-guide/pdf/SpinPapi-v2.pdf
  233. *
  234. * @param array $qp Query parameters. Array with one parameter per array
  235. * element. Parameter name is element key and parameter
  236. * value is array value. 'method' element MUST be present.
  237. * 'station', 'papiuser', 'papiversion' SHOULD NOT be
  238. * included.
  239. *
  240. * @return array Data returned by SpinPapi or false on error.
  241. */
  242. public function queryNoCache($qp) {
  243. $qp = $qp + $this->qp;
  244. // SpinPapi requires acurate request timestamp in GMT
  245. $qp['timestamp'] = gmdate('Y-m-d\TH:i:s\Z');
  246. // Form the request's GET query string.
  247. ksort($qp);
  248. $query = array();
  249. foreach ($qp as $p => $v) {
  250. $query[] = rawurlencode($p) . '=' . rawurlencode($v);
  251. }
  252. $query = implode('&', $query);
  253. // Ensure the timestamp isn't reused.
  254. unset($qp['timestamp']);
  255. // Calculate request signature.
  256. $signature = rawurlencode(base64_encode(hash_hmac(
  257. "sha256",
  258. $this->spHost . "\n" . $this->spUrl . "\n" . $query,
  259. $this->secret,
  260. true
  261. )));
  262. // Form the request string.
  263. $request = $this->spHost . $this->spUrl . "?$query&signature=$signature";
  264. // Send the request and receive the result data.
  265. $data = file_get_contents('http://' . $request);
  266. if ($data === false) {
  267. return false;
  268. }
  269. $data = json_decode($data, 1);
  270. if ($data === false) {
  271. return false;
  272. }
  273. $this->prettyJson = json_encode($data, JSON_PRETTY_PRINT);
  274. return $data;
  275. }
  276. /**
  277. * Generate a cache key for a specific SpinPapi query result.
  278. *
  279. * If a Memcached object available, returns a key for that, otherwise a key
  280. * for the file cache (whether its available or not).
  281. *
  282. * @param array $qp Query parameters. Array with one parameter per array
  283. * element. Parameter name is element key and parameter
  284. * value is array value. 'method' element MUST be present.
  285. * 'station', 'papiuser', 'papiversion' SHOULD NOT be
  286. * included.
  287. *
  288. * @return string A cache key.
  289. */
  290. public function key($qp) {
  291. $qp = $this->qp + $qp;
  292. $key = $this->spMethods[$qp['method']];
  293. foreach ($this->spParams as $p => $abv) {
  294. if (isset($qp[$p])) {
  295. $key .= '_' . $abv . '=' . $qp[$p];
  296. }
  297. }
  298. $key = preg_replace('/[^a-z0-9_=-]/', '', $key);
  299. return $this->mc ? 'SP:' . $key : $key;
  300. }
  301. /**
  302. * Figure the cache lifetime for a specific SpinPapi query result using a
  303. * default generic, application-neutral cache policy.
  304. *
  305. * If you modify this and you use the file cache, make consistent changes
  306. * in SpinPapiClient::fcGC().
  307. *
  308. * @param array $qp Query parameters. Array with one parameter per array
  309. * element. Parameter name is element key and parameter
  310. * value is array value. 'method' element MUST be present.
  311. * 'station', 'papiuser', 'papiversion' SHOULD NOT be
  312. * included.
  313. *
  314. * @return int Cache lifetime of the query result in seconds.
  315. */
  316. public function expires($qp) {
  317. $qp = $this->qp + $qp;
  318. // Figuring the expiration time is a little complex. See the policy comments
  319. // in the README file.
  320. if ($qp['method'] == 'getDJ') {
  321. $expires = self::EXP_L;
  322. } elseif ($qp['method'] == 'getDJs') {
  323. $expires = self::EXP_M;
  324. } elseif ($qp['method'] == 'getPlaylistsInfo' && isset($qp['UserID']) && $qp['UserID']) {
  325. $expires = self::EXP_M;
  326. } elseif (isset($qp['SongID']) && $qp['SongID']) {
  327. $expires = self::EXP_M;
  328. } elseif (isset($qp['PlaylistID']) && $qp['PlaylistID']) {
  329. $expires = self::EXP_L;
  330. } elseif ($qp['method'] === 'getRegularShowsInfo') {
  331. if (isset($qp['When'])) {
  332. if (preg_match('/^[0-7]$/', $qp['When'])) {
  333. $expires = self::EXP_L;
  334. } elseif ($qp['When'] === 'now') {
  335. $expires = self::EXP_S;
  336. } elseif ($qp['When'] === 'today') {
  337. $expires = strtotime('tomorrow') - time();
  338. $expires
  339. += isset($qp['StartHour'])
  340. && is_numeric($qp['StartHour'])
  341. && $qp['StartHour'] >= 0
  342. && $qp['StartHour'] <= 24
  343. ? $qp['StartHour']*3600
  344. : 21600;
  345. $expires %= 86400;
  346. $expires = min(self::EXP_L, $expires);
  347. } else {
  348. $expires = self::EXP_L;
  349. }
  350. } else {
  351. $expires = self::EXP_L;
  352. }
  353. } else {
  354. $expires = self::EXP_S;
  355. }
  356. return $expires;
  357. }
  358. /**
  359. * Set a file cache entry.
  360. *
  361. * @param string $key A cache key, e.g. as provided by SpinPapi::key().
  362. * @param mixed $data The data to cache in any serializable type.
  363. *
  364. * @return bool True on success, false on serialize or file system
  365. * write error.
  366. */
  367. private function fcSet($key, $data) {
  368. $data = @serialize($data);
  369. if (!$data) {
  370. return false;
  371. }
  372. return file_put_contents($this->fcDir . '/' . $key, $data);
  373. }
  374. /**
  375. * Get a file cache entry.
  376. *
  377. * @param string $key A cache key, e.g. as provided by SpinPapi::key().
  378. * @param int $expires Lifetime of the cache entry in seconds.
  379. *
  380. * @return mixed The cached data in its original type or false on
  381. * cache miss or file system read or unsearialize error.
  382. */
  383. private function fcGet($key, $expires) {
  384. $f = $this->fcDir . '/' . $key;
  385. if (file_exists($f) && is_readable($f)) {
  386. if (filemtime($f) > time() - $expires) {
  387. $data = file_get_contents($f);
  388. if ($data) {
  389. $data = unserialize($data);
  390. if ($data) {
  391. // Cache hit. Return the cached data.
  392. return $data;
  393. }
  394. }
  395. } else {
  396. // The cache entry expired. Delete it.
  397. unlink($f);
  398. }
  399. }
  400. return false;
  401. }
  402. /**
  403. * Run the file cache garbage collector.
  404. *
  405. * Run this from time to time to delete all expired file cache entries. If
  406. * you midify this, the expiration times should be consistent with
  407. * SpinPapiClient::expires().
  408. *
  409. * Note: The garbage collector doesn't know about default policy cache
  410. * lifetime overrides you might have used when calling SpinPapi::query().
  411. *
  412. * @return bool True.
  413. */
  414. public function fcGC() {
  415. if (!$this->fcDir) {
  416. return false;
  417. }
  418. $exps = array(
  419. 'sg_s' => self::EXP_M, 'sg' => self::EXP_S,
  420. 'ss_p' => self::EXP_L, 'ss' => self::EXP_S,
  421. 'cp' => self::EXP_S,
  422. 'pi_p' => self::EXP_L, 'pi' => self::EXP_S,
  423. 'ps' => self::EXP_M,
  424. 'si' => self::EXP_S,
  425. 'rs_w=now' => self::EXP_S, 'rs' => self::EXP_L,
  426. );
  427. $files = glob($this->fcDir . '/*', GLOB_NOSORT);
  428. if ($files) {
  429. $now = time();
  430. foreach ($files as $file) {
  431. foreach ($exps as $pfx => $exp) {
  432. if (substr(basename($file), 0, strlen($pfx)) === $pfx) {
  433. $mtime = filemtime($file);
  434. if ($mtime && filemtime($file) < $now - $exp) {
  435. unlink($file);
  436. }
  437. continue 2;
  438. }
  439. }
  440. }
  441. }
  442. return true;
  443. }
  444. /**
  445. * Expire the current song in the data cache.
  446. *
  447. * @return bool False on Memcached or file delete error, true otherwise.
  448. */
  449. public function newNowPlaying() {
  450. if ($this->mc) {
  451. $this->mc->delete('SP:sg');
  452. return $this->mc->getResultCode() === Memcached::RES_SUCCESS;
  453. } elseif ($this->fcDir) {
  454. return file_exists($this->fcDir . '/sg')
  455. ? unlink($this->fcDir . '/sg')
  456. : true;
  457. } else {
  458. return true;
  459. }
  460. }
  461. /**
  462. * Get SpinPapi data, possibly from cache. Update cache as appropriate. Run
  463. * garbage collector at random.
  464. *
  465. * @see SpinPapi API Specification.
  466. * @link http://spinitron.com/user-guide/pdf/SpinPapi-v2.pdf
  467. *
  468. * @param array $qp Query parameters. Array with one parameter per array
  469. * element. Parameter name is element key and parameter value is array value.
  470. * 'method' element MUST be present. 'station', 'papiuser', 'papiversion'
  471. * SHOULD NOT be included.
  472. * @param bool|int $expires Override default policy cache lifetime. Seconds.
  473. *
  474. * @return array Data returned by SpinPapi or false on error.
  475. */
  476. public function query($qp, $expires = false) {
  477. $qp = $qp + $this->qp;
  478. if (false && $this->fcDir && mt_rand(1, self::GC_RATIO) === 1) {
  479. $this->fcGC();
  480. }
  481. if (!isset($this->spMethods[$qp['method']])) {
  482. // Method is either absent from query params or unrecognized.
  483. return false;
  484. }
  485. // If we have a valid initialized cache, get a key for the query.
  486. if ($this->mc || $this->fcDir) {
  487. $key = $this->key($qp);
  488. }
  489. // Check the memcache, if there is one.
  490. if ($this->mc) {
  491. $data = $this->mc->get($key);
  492. if ($data !== false
  493. && $this->mc->getResultCode() !== Memcached::RES_NOTFOUND
  494. ) {
  495. // Cache hit. Return the cached data.
  496. return $data;
  497. }
  498. }
  499. // Check the file cache, if there is one.
  500. if ($this->fcDir) {
  501. $data = $this->fcGet($key, $expires ? $expires : $this->expires($qp));
  502. if ($data !== false) {
  503. // Cache hit. Return the cached data.
  504. return $data;
  505. }
  506. }
  507. // Caches (if any) missed. Request the data from Spinitron.
  508. $data = $this->queryNoCache($qp);
  509. // False or empty $data means the request failed.
  510. if (!$data) {
  511. if ($this->logErrors) {
  512. trigger_error("SpinPapi request failed.");
  513. }
  514. return false;
  515. }
  516. if ($this->logErrors && !$data['success']) {
  517. $message = 'SpinPapi request failed.';
  518. if (isset($data['errors']) && $data['errors']) {
  519. $message .= ' Errors: ' . implode(". ", $data['errors']) . '.';
  520. }
  521. trigger_error($message);
  522. }
  523. // If request went through and the response from Spinitron is valid, cache
  524. // it, whether the query was sucessful or not.
  525. if ($this->mc) {
  526. $wr = $this->mc->set(
  527. $key, $data,
  528. $expires ? $expires : $this->expires($qp)
  529. );
  530. if ($this->logErrors && $wr === false) {
  531. trigger_error(
  532. "Memcached::set() error: " . $this->mc->getResultMessage()
  533. );
  534. }
  535. } elseif ($this->fcDir) {
  536. $wr = $this->fcSet($key, $data);
  537. if ($this->logErrors && $wr === false) {
  538. trigger_error("Error writing to dir: " . $this->fcDir);
  539. }
  540. }
  541. return $data;
  542. }
  543. }