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