From 539ca08efce69bfa53c5abbf44e014ea6fee1b4c Mon Sep 17 00:00:00 2001 From: Afterster Date: Sat, 2 Nov 2013 15:17:34 +0100 Subject: [PATCH] Add XBMC Localplay --- README.md | 1 + modules/localplay/xbmc.controller.php | 633 ++++++++++++++++++ modules/xbmc-php-rpc/Client.php | 327 +++++++++ modules/xbmc-php-rpc/ClientException.php | 7 + modules/xbmc-php-rpc/Command.php | 86 +++ modules/xbmc-php-rpc/CommandException.php | 7 + modules/xbmc-php-rpc/ConnectionException.php | 7 + modules/xbmc-php-rpc/Exception.php | 5 + modules/xbmc-php-rpc/HTTPClient.php | 136 ++++ .../xbmc-php-rpc/InvalidCommandException.php | 7 + .../InvalidNamespaceException.php | 7 + modules/xbmc-php-rpc/Namespace.php | 166 +++++ modules/xbmc-php-rpc/RequestException.php | 7 + modules/xbmc-php-rpc/Response.php | 66 ++ modules/xbmc-php-rpc/ResponseException.php | 7 + modules/xbmc-php-rpc/Server.php | 88 +++ modules/xbmc-php-rpc/ServerException.php | 7 + modules/xbmc-php-rpc/TCPClient.php | 139 ++++ 18 files changed, 1703 insertions(+) create mode 100644 modules/localplay/xbmc.controller.php create mode 100644 modules/xbmc-php-rpc/Client.php create mode 100644 modules/xbmc-php-rpc/ClientException.php create mode 100644 modules/xbmc-php-rpc/Command.php create mode 100644 modules/xbmc-php-rpc/CommandException.php create mode 100644 modules/xbmc-php-rpc/ConnectionException.php create mode 100644 modules/xbmc-php-rpc/Exception.php create mode 100644 modules/xbmc-php-rpc/HTTPClient.php create mode 100644 modules/xbmc-php-rpc/InvalidCommandException.php create mode 100644 modules/xbmc-php-rpc/InvalidNamespaceException.php create mode 100644 modules/xbmc-php-rpc/Namespace.php create mode 100644 modules/xbmc-php-rpc/RequestException.php create mode 100644 modules/xbmc-php-rpc/Response.php create mode 100644 modules/xbmc-php-rpc/ResponseException.php create mode 100644 modules/xbmc-php-rpc/Server.php create mode 100644 modules/xbmc-php-rpc/ServerException.php create mode 100644 modules/xbmc-php-rpc/TCPClient.php diff --git a/README.md b/README.md index 6e78d65f..a42173dc 100755 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Ampache includes some external modules that carry their own licensing. * [Prototype](http://www.prototypejs.org/): MIT * [Snoopy](http://snoopy.sourceforge.net/): LGPL v2.1 * [Whatever:hover](http://www.xs4all.nl/~peterned): LGPL v2.1 +* [xbmc-php-rpc](https://github.com/karlrixon/xbmc-php-rpc): GPL v3 Translations ------------ diff --git a/modules/localplay/xbmc.controller.php b/modules/localplay/xbmc.controller.php new file mode 100644 index 00000000..f7539e1e --- /dev/null +++ b/modules/localplay/xbmc.controller.php @@ -0,0 +1,633 @@ +description; + + } // get_description + + /** + * get_version + * This returns the current version + */ + public function get_version() { + + return $this->version; + + } // get_version + + /** + * is_installed + * This returns true or false if xbmc controller is installed + */ + public function is_installed() { + + $sql = "DESCRIBE `localplay_xbmc`"; + $db_results = Dba::query($sql); + + return Dba::num_rows($db_results); + + + } // is_installed + + /** + * install + * This function installs the xbmc localplay controller + */ + public function install() { + + $sql = "CREATE TABLE `localplay_xbmc` (`id` INT( 11 ) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY , ". + "`name` VARCHAR( 128 ) COLLATE utf8_unicode_ci NOT NULL , " . + "`owner` INT( 11 ) NOT NULL, " . + "`host` VARCHAR( 255 ) COLLATE utf8_unicode_ci NOT NULL , " . + "`port` INT( 11 ) UNSIGNED NOT NULL , " . + "`user` VARCHAR( 255 ) COLLATE utf8_unicode_ci NOT NULL , " . + "`pass` VARCHAR( 255 ) COLLATE utf8_unicode_ci NOT NULL" . + ") ENGINE = MYISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci"; + $db_results = Dba::query($sql); + + // Add an internal preference for the users current active instance + Preference::insert('xbmc_active','XBMC Active Instance','0','25','integer','internal'); + User::rebuild_all_preferences(); + + return true; + + } // install + + /** + * uninstall + * This removes the localplay controller + */ + public function uninstall() { + + $sql = "DROP TABLE `localplay_xbmc`"; + $db_results = Dba::query($sql); + + // Remove the pref we added for this + Preference::delete('xbmc_active'); + + return true; + + } // uninstall + + /** + * add_instance + * This takes key'd data and inserts a new xbmc instance + */ + public function add_instance($data) { + + $sql = "INSERT INTO `localplay_xbmc` (`name`,`host`,`port`, `user`, `pass`,`owner`) " . + "VALUES (?, ?, ?, ?, ?, ?)"; + $db_results = Dba::query($sql, array($data['name'], $data['host'], $data['port'], $data['user'], $data['pass'], $GLOBALS['user']->id)); + + return $db_results; + + } // add_instance + + /** + * delete_instance + * This takes a UID and deletes the instance in question + */ + public function delete_instance($uid) { + + $sql = "DELETE FROM `localplay_xbmc` WHERE `id` = ?"; + $db_results = Dba::query($sql, array($uid)); + + return true; + + } // delete_instance + + /** + * get_instances + * This returns a key'd array of the instance information with + * [UID]=>[NAME] + */ + public function get_instances() { + + $sql = "SELECT * FROM `localplay_xbmc` ORDER BY `name`"; + $db_results = Dba::query($sql); + + $results = array(); + + while ($row = Dba::fetch_assoc($db_results)) { + $results[$row['id']] = $row['name']; + } + + return $results; + + } // get_instances + + /** + * update_instance + * This takes an ID and an array of data and updates the instance specified + */ + public function update_instance($uid,$data) { + + $sql = "UPDATE `localplay_xbmc` SET `host` = ?, `port` = ?, `name` = ?, `user` = ?, `pass` = ? WHERE `id` = ?"; + $db_results = Dba::query($sql, array($data['host'], $data['port'], $data['name'], $data['user'], $data['pass'], $uid)); + + return true; + + } // update_instance + + /** + * instance_fields + * This returns a key'd array of [NAME]=>array([DESCRIPTION]=>VALUE,[TYPE]=>VALUE) for the + * fields so that we can on-the-fly generate a form + */ + public function instance_fields() { + + $fields['name'] = array('description' => T_('Instance Name'),'type'=>'textbox'); + $fields['host'] = array('description' => T_('Hostname'),'type'=>'textbox'); + $fields['port'] = array('description' => T_('Port'),'type'=>'textbox'); + $fields['user'] = array('description' => T_('Username'),'type'=>'textbox'); + $fields['pass'] = array('description' => T_('Password'),'type'=>'textbox'); + + return $fields; + + } // instance_fields + + /** + * get_instance + * This returns a single instance and all it's variables + */ + public function get_instance($instance='') { + + $instance = $instance ? $instance : Config::get('xbmc_active'); + + $sql = "SELECT * FROM `localplay_xbmc` WHERE `id` = ?"; + $db_results = Dba::query($sql, array($instance)); + + $row = Dba::fetch_assoc($db_results); + + return $row; + + } // get_instance + + /** + * set_active_instance + * This sets the specified instance as the 'active' one + */ + public function set_active_instance($uid,$user_id='') { + + // Not an admin? bubkiss! + if (!$GLOBALS['user']->has_access('100')) { + $user_id = $GLOBALS['user']->id; + } + + $user_id = $user_id ? $user_id : $GLOBALS['user']->id; + + Preference::update('xbmc_active', $user_id, intval($uid)); + Config::set('xbmc_active', intval($uid), true); + + return true; + + } // set_active_instance + + /** + * get_active_instance + * This returns the UID of the current active instance + * false if none are active + */ + public function get_active_instance() { + + + } // get_active_instance + + public function add_url(Stream_URL $url) { + + try { + $this->_xbmc->Playlist->Add(array( + 'playlistid' => $this->_playlistId, + 'item' => array('file' => $url->url) + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'add_url failed: ' . $ex->getMessage(), 1); + return false; + } + } + + /** + * delete_track + * Delete a track from the xbmc playlist + */ + public function delete_track($track) { + + try { + $this->_xbmc->Playlist->Remove(array( + 'playlistid' => $this->_playlistId, + 'position' => $track + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'delete_track failed: ' . $ex->getMessage(), 1); + return false; + } + + } // delete_track + + /** + * clear_playlist + * This deletes the entire xbmc playlist. + */ + public function clear_playlist() { + + try { + $this->_xbmc->Playlist->Clear(array( + 'playlistid' => $this->_playlistId + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'clear_playlist failed: ' . $ex->getMessage(), 1); + return false; + } + + } // clear_playlist + + /** + * play + * This just tells xbmc to start playing, it does not + * take any arguments + */ + public function play() { + + try { + // XBMC requires to load a playlist to play. We don't know if this play is after a new playlist or after pause + // So we get current status + $status = $this->status(); + if ($status['state'] == 'stop') { + $this->_xbmc->Player->Open(array( + 'item' => array('playlistid' => $this->_playlistId)) + ); + } else { + $this->_xbmc->Player->PlayPause(array( + 'playerid' => $this->_playlistId, + 'play' => true) + ); + } + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'play failed: ' . $ex->getMessage(), 1); + return false; + } + + } // play + + /** + * pause + * This tells xbmc to pause the current song + */ + public function pause() { + + try { + $this->_xbmc->Player->PlayPause(array( + 'playerid' => $this->_playerId, + 'play' => false) + ); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'pause failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // pause + + /** + * stop + * This just tells xbmc to stop playing, it does not take + * any arguments + */ + public function stop() { + + try { + $this->_xbmc->Player->Stop(array( + 'playerid' => $this->_playerId + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'stop failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // stop + + /** + * skip + * This tells xbmc to skip to the specified song + */ + public function skip($song) { + + try { + $this->_xbmc->Player->GoTo(array( + 'playerid' => $this->_playerId, + 'to' => $song + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'skip failed, is the player started?: ' . $ex->getMessage(), 1); + return false; + } + + } // skip + + /** + * This tells xbmc to increase the volume + */ + public function volume_up() { + + try { + $this->_xbmc->Application->SetVolume(array( + 'volume' => 'increment' + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'volume_up failed: ' . $ex->getMessage(), 1); + return false; + } + + } // volume_up + + /** + * This tells xbmc to decrease the volume + */ + public function volume_down() { + + try { + $this->_xbmc->Application->SetVolume(array( + 'volume' => 'decrement' + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'volume_down failed: ' . $ex->getMessage(), 1); + return false; + } + + } // volume_down + + /** + * next + * This just tells xbmc to skip to the next song + */ + public function next() { + + try { + $this->_xbmc->Player->GoTo(array( + 'playerid' => $this->_playerId, + 'to' => 'next' + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'next failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // next + + /** + * prev + * This just tells xbmc to skip to the prev song + */ + public function prev() { + + try { + $this->_xbmc->Player->GoTo(array( + 'playerid' => $this->_playerId, + 'to' => 'previous' + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'prev failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // prev + + /** + * volume + * This tells xbmc to set the volume to the specified amount + */ + public function volume($volume) { + + try { + $this->_xbmc->Application->SetVolume(array( + 'volume' => $volume + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'volume failed: ' . $ex->getMessage(), 1); + return false; + } + + } // volume + + /** + * repeat + * This tells xbmc to set the repeating the playlist (i.e. loop) to either on or off + */ + public function repeat($state) { + + try { + $this->_xbmc->Player->SetRepeat(array( + 'playerid' => $this->_playerId, + 'repeat' => ($state ? 'all' : 'off') + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'repeat failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // repeat + + /** + * random + * This tells xbmc to turn on or off the playing of songs from the playlist in random order + */ + public function random($onoff) { + + try { + $this->_xbmc->Player->SetShuffle(array( + 'playerid' => $this->_playerId, + 'shuffle' => $onoff + )); + return true; + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'random failed, is the player started? ' . $ex->getMessage(), 1); + return false; + } + + } // random + + /** + * get + * This functions returns an array containing information about + * The songs that xbmc currently has in it's playlist. This must be + * done in a standardized fashion + */ + public function get() { + + $results = array(); + + try { + $playlist = $this->_xbmc->Playlist->GetItems(array( + 'playlistid' => $this->_playlistId, + 'properties' => array('file') + )); + + for ($i = $playlist['limits']['start']; $i < $playlist['limits']['end']; ++$i) { + $item = $playlist['items'][$i]; + + $data = array(); + $data['link'] = $item['file']; + $data['id'] = $i; + $data['track'] = $i + 1; + + $url_data = $this->parse_url($data['link']); + if ($url_data != null) { + $song = new Song($url_data['oid']); + if ($song != null) { + $data['name'] = $song->get_artist_name() . ' - ' . $song->title; + } + } + if (!$data['name']) { + $data['name'] = $item['label']; + } + $results[] = $data; + } + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'get failed: ' . $ex->getMessage(), 1); + } + + return $results; + + } // get + + /** + * status + * This returns bool/int values for features, loop, repeat and any other features + * that this localplay method supports. + * This works as in requesting the xbmc properties + */ + public function status() { + + $array = array(); + try { + $appprop = $this->_xbmc->Application->GetProperties(array( + 'properties' => array('volume') + )); + $array['volume'] = intval($appprop['volume']); + + try { + $currentplay = $this->_xbmc->Player->GetItem(array( + 'playerid' => $this->_playerId, + 'properties' => array('file') + )); + // We assume it's playing. No pause detection support. + $array['state'] = 'play'; + + $playprop = $this->_xbmc->Player->GetProperties(array( + 'playerid' => $this->_playerId, + 'properties' => array('repeat', 'shuffled') + )); + $array['repeat'] = ($playprop['repeat'] != "off"); + $array['random'] = (strtolower($playprop['shuffled']) == 1) ; + $array['track'] = $currentplay['file']; + + $url_data = $this->parse_url($array['track']); + $song = new Song($url_data['oid']); + if ($song->title || $song->get_artist_name() || $song->get_album_name()) { + $array['track_title'] = $song->title; + $array['track_artist'] = $song->get_artist_name(); + $array['track_album'] = $song->get_album_name(); + } + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'get current item failed, player probably stopped. ' . $ex->getMessage(), 1); + $array['state'] = 'stop'; + } + } catch (XBMC_RPC_Exception $ex) { + debug_event('xbmc', 'status failed: ' . $ex->getMessage(), 1); + } + return $array; + + } // status + + /** + * connect + * This functions creates the connection to xbmc and returns + * a boolean value for the status, to save time this handle + * is stored in this class + */ + public function connect() { + + $options = self::get_instance(); + try { + $this->_xbmc = new XBMC_RPC_TCPClient($options); + return true; + } catch (XBMC_RPC_ConnectionException $ex) { + debug_event('xbmc', 'xbmc connection failed: ' . $ex->getMessage(), 1); + return false; + } + + } // connect + +} //end of AmpacheXbmc + +?> diff --git a/modules/xbmc-php-rpc/Client.php b/modules/xbmc-php-rpc/Client.php new file mode 100644 index 00000000..cb935b13 --- /dev/null +++ b/modules/xbmc-php-rpc/Client.php @@ -0,0 +1,327 @@ +getMessage(), $e->getCode(), $e); + } + $this->server = $server; + $this->prepareConnection(); + $this->assertCanConnect(); + $this->createRootNamespace(); + } + + /** + * Delegates any direct Command calls to the root namespace. + * + * @param string $name The name of the called command. + * @param mixed $arguments An array of arguments used to call the command. + * @return The result of the command call as returned from the namespace. + * @exception XBMC_RPC_InvalidCommandException if the called command does not + * exist in the root namespace. + * @access public + */ + public function __call($name, array $arguments) { + return call_user_func_array(array($this->rootNamespace, $name), $arguments); + } + + /** + * Delegates namespace accesses to the root namespace. + * + * @param string $name The name of the requested namespace. + * @return XBMC_RPC_Namespace The requested namespace. + * @exception XBMC_RPC_InvalidNamespaceException if the namespace does not + * exist in the root namespace. + * @access public + */ + public function __get($name) { + return $this->rootNamespace->$name; + } + + /** + * Executes a remote procedure call using the supplied XBMC_RPC_Command + * object. + * + * @param XBMC_RPC_Command The command to execute. + * @return XBMC_RPC_Response The response from the remote procedure call. + * @access public + */ + public function executeCommand(XBMC_RPC_Command $command) { + return $this->sendRpc($command->getFullName(), $command->getArguments()); + } + + /** + * Determines if the XBMC system to which the client is connected is legacy + * (pre Eden) or not. This is useful because the format of commands/params + * is different in the Eden RPC implementation. + * + * @return bool True if the system is legacy, false if not. + * @access public + */ + public function isLegacy() { + return $this->isLegacy; + } + + /** + * Asserts that the server is reachable and a connection can be made. + * + * @return void + * @exception XBMC_RPC_ConnectionException if it is not possible to connect to + * the server. + * @abstract + * @access protected + */ + protected abstract function assertCanConnect(); + + /** + * Prepares for a connection to XBMC. + * + * Should be used by child classes for any pre-connection logic which is necessary. + * + * @return void + * @exception XBMC_RPC_ClientException if it was not possible to prepare for + * connection successfully. + * @abstract + * @access protected + */ + protected abstract function prepareConnection(); + + /** + * Sends a JSON-RPC request to XBMC and returns the result. + * + * @param string $json A JSON-encoded string representing the remote procedure call. + * This string should conform to the JSON-RPC 2.0 specification. + * @param string $rpcId The unique ID of the remote procedure call. + * @return string The JSON-encoded response string from the server. + * @exception XBMC_RPC_RequestException if it was not possible to make the request. + * @access protected + * @link http://groups.google.com/group/json-rpc/web/json-rpc-2-0 JSON-RPC 2.0 specification + */ + protected abstract function sendRequest($json, $rpcId); + + /** + * Build a JSON-RPC 2.0 compatable json_encoded string representing the + * specified command, parameters and request id. + * + * @param string $command The name of the command to be called. + * @param mixed $params An array of paramters to be passed to the command. + * @param string $rpcId A unique string used for identifying the request. + * @access private + */ + private function buildJson($command, $params, $rpcId) { + $data = array( + 'jsonrpc' => '2.0', + 'method' => $command, + 'params' => $params, + 'id' => $rpcId + ); + return json_encode($data); + } + + /** + * Ensures that the recieved response from a remote procedure call is valid. + * + * $param XBMC_RPC_Response $response A response object encapsulating remote + * procedure call response data as returned from Client::sendRequest(). + * @return bool True of the reponse is valid, false if not. + * @access private + */ + private function checkResponse(XBMC_RPC_Response $response, $rpcId) { + return ($response->getId() == $rpcId); + } + + /** + * Creates the root namespace instance. + * + * @return void + * @access private + */ + private function createRootNamespace() { + $commands = $this->loadAvailableCommands(); + $this->rootNamespace = new XBMC_RPC_Namespace('root', $commands, $this); + } + + /** + * Generates a unique string to be used as a remote procedure call ID. + * + * @return string A unique string. + * @access private + */ + private function getRpcId() { + return uniqid(); + } + + /** + * Retrieves an array of commands by requesting the RPC server to introspect. + * + * @return mixed An array of available commands which may be executed on the server. + * @exception XBMC_RPC_RequestException if it is not possible to retrieve a list of + * available commands. + * @access private + */ + private function loadAvailableCommands() { + try { + $response = $this->sendRpc('JSONRPC.Introspect'); + } catch (XBMC_RPC_Exception $e) { + throw new XBMC_RPC_RequestException( + 'Unable to retrieve list of available commands: ' . $e->getMessage() + ); + } + if (isset($response['commands'])) { + $this->isLegacy = true; + return $this->loadAvailableCommandsLegacy($response); + } + $commands = array(); + foreach (array_keys($response['methods']) as $command) { + $array = $this->commandStringToArray($command); + $commands = $this->mergeCommandArrays($commands, $array); + } + return $commands; + } + + /** + * Retrieves an array of commands by requesting the RPC server to introspect. + * + * This method supports the legacy implementation of XBMC's RPC. + * + * @return mixed An array of available commands which may be executed on the server. + * @access private + */ + private function loadAvailableCommandsLegacy($response) { + $commands = array(); + foreach ($response['commands'] as $command) { + $array = $this->commandStringToArray($command['command']); + $commands = $this->mergeCommandArrays($commands, $array); + } + return $commands; + } + + /** + * Converts a dot-delimited command name to a multidimensional array format. + * + * @return mixed An array representing the command. + * @access private + */ + private function commandStringToArray($command) { + $path = explode('.', $command); + if (count($path) === 1) { + $commands[] = $path[0]; + continue; + } + $command = array_pop($path); + $array = array(); + $reference =& $array; + foreach ($path as $i => $key) { + if (is_numeric($key) && intval($key) > 0 || $key === '0') { + $key = intval($key); + } + if ($i === count($path) - 1) { + $reference[$key] = array($command); + } else { + if (!isset($reference[$key])) { + $reference[$key] = array(); + } + $reference =& $reference[$key]; + } + } + return $array; + } + + /** + * Recursively merges the supplied arrays whilst ensuring that commands are + * not duplicated within a namespace. + * + * Note that array_merge_recursive is not suitable here as it does not ensure + * that values are distinct within an array. + * + * @param mixed $base The base array into which $append will be merged. + * @param mixed $append The array to merge into $base. + * @return mixed The merged array of commands and namespaces. + * @access private + */ + private function mergeCommandArrays(array $base, array $append) { + foreach ($append as $key => $value) { + if (!array_key_exists($key, $base) && !is_numeric($key)) { + $base[$key] = $append[$key]; + continue; + } + if (is_array($value) || is_array($base[$key])) { + $base[$key] = $this->mergeCommandArrays($base[$key], $append[$key]); + } elseif (is_numeric($key)) { + if (!in_array($value, $base)) { + $base[] = $value; + } + } else { + $base[$key] = $value; + } + } + return $base; + } + + /** + * Executes a remote procedure call using the supplied command name and parameters. + * + * @param string $command The full, dot-delimited name of the command to call. + * @param mixed $params An array of parameters to be passed to the called method. + * @return mixed The data returned from the response. + * @exception XBMC_RPC_RequestException if it was not possible to make the request. + * @exception XBMC_RPC_ResponseException if the response was not being properly received. + * @access private + */ + private function sendRpc($command, $params = array()) { + $rpcId = $this->getRpcId(); + $json = $this->buildJson($command, $params, $rpcId); + $response = new XBMC_RPC_Response($this->sendRequest($json, $rpcId)); + if (!$this->checkResponse($response, $rpcId)) { + throw new XBMC_RPC_ResponseException('JSON RPC request/response ID mismatch'); + } + return $response->getData(); + } + +} \ No newline at end of file diff --git a/modules/xbmc-php-rpc/ClientException.php b/modules/xbmc-php-rpc/ClientException.php new file mode 100644 index 00000000..060d0bbc --- /dev/null +++ b/modules/xbmc-php-rpc/ClientException.php @@ -0,0 +1,7 @@ +name = $name; + $this->parentNamespace = $parent; + $this->client = $client; + } + + /** + * Executes the remote procedure call command. + * + * @param mixed $arguments An array of arguments to be passed along with the + * command. + * @return mixed The response data as returned from XBMC_RPC_Client::sendRpc(). + * @exception XBMC_RPC_Exception if the remote procedure call could be carried + * out successfully. + * @access public + */ + public function execute(array $arguments = array()) { + if (count($arguments) == 1) { + $arguments = array_shift($arguments); + } + $this->arguments = $arguments; + return $this->client->executeCommand($this); + } + + /** + * Gets an array of arguments which accompany the command. + * + * @return mixed The array of argument which accompany this command. + * @access public + */ + public function getArguments() { + return $this->arguments; + } + + /** + * Gets the full, dot-delimited name of the command including its namespace path. + * + * @return string The command name. + * @access public + */ + public function getFullName() { + return $this->parentNamespace->getFullName() . '.' . $this->name; + } + +} \ No newline at end of file diff --git a/modules/xbmc-php-rpc/CommandException.php b/modules/xbmc-php-rpc/CommandException.php new file mode 100644 index 00000000..a9deed26 --- /dev/null +++ b/modules/xbmc-php-rpc/CommandException.php @@ -0,0 +1,7 @@ +curlResource)) { + curl_close($this->curlResource); + } + } + + /** + * Sets the number of seconds the script will wait while trying to connect + * to the server before giving up. + * + * @param int $seconds The number of seconds to wait. + * @return void + */ + public function setTimeout($seconds) { + $this->timeout = (int) $seconds; + if ($this->timeout < 0) { + $this->timeout = 0; + } + } + + /** + * Asserts that the server is reachable and a connection can be made. + * + * @return void + * @exception XBMC_RPC_ConnectionException if it is not possible to connect to + * the server. + */ + protected function assertCanConnect() { + if (extension_loaded('curl')) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_URL, $this->uri); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + if (!curl_exec($ch) || !in_array(curl_getinfo($ch, CURLINFO_HTTP_CODE), array('200', '401'))) { + throw new XBMC_RPC_ConnectionException('Unable to connect to XBMC server via HTTP'); + } + } else { + throw new XBMC_RPC_ConnectionException('Cannot test if XBMC server is reachable via HTTP because cURL is not installed'); + } + } + + /** + * Prepares for a connection to XBMC via HTTP. + * + * @exception XBMC_RPC_ClientException if it was not possible to prepare for + * connection successfully. + * @access protected + */ + protected function prepareConnection() { + if (!$uri = $this->buildUri()) { + throw new XBMC_RPC_ClientException('Unable to parse server parameters into valid URI string'); + } + $this->uri = $uri; + } + + /** + * Sends a JSON-RPC request to XBMC and returns the result. + * + * @param string $json A JSON-encoded string representing the remote procedure call. + * This string should conform to the JSON-RPC 2.0 specification. + * @param string $rpcId The unique ID of the remote procedure call. + * @return string The JSON-encoded response string from the server. + * @exception XBMC_RPC_RequestException if it was not possible to make the request. + * @access protected + * @link http://groups.google.com/group/json-rpc/web/json-rpc-2-0 JSON-RPC 2.0 specification + */ + protected function sendRequest($json, $rpcId) { + if (empty($this->curlResource)) { + $this->curlResource = $this->createCurlResource(); + } + curl_setopt($this->curlResource, CURLOPT_POSTFIELDS, $json); + curl_setopt($this->curlResource, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); + if (!$response = curl_exec($this->curlResource)) { + throw new XBMC_RPC_RequestException('Could not make a request the server'); + } + return $response; + } + + /** + * Builds the server URI from the supplied parameters. + * + * @return string The server URI. + * @access private + */ + private function buildUri() { + $parameters = $this->server->getParameters(); + $credentials = ''; + if (!empty($parameters['user'])) { + $credentials = $parameters['user']; + $credentials .= empty($parameters['pass']) ? '@' : ':' . $parameters['pass'] . '@'; + } + return sprintf('http://%s%s:%d/jsonrpc', $credentials, $parameters['host'], $parameters['port']); + } + + /** + * Creates a curl resource with the correct settings for making JSON-RPC calls + * to XBMC. + * + * @return resource A new curl resource. + * @access private + */ + private function createCurlResource() { + $curlResource = curl_init(); + curl_setopt($curlResource, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curlResource, CURLOPT_POST, 1); + curl_setopt($curlResource, CURLOPT_URL, $this->uri); + return $curlResource; + } + +} diff --git a/modules/xbmc-php-rpc/InvalidCommandException.php b/modules/xbmc-php-rpc/InvalidCommandException.php new file mode 100644 index 00000000..36f7e259 --- /dev/null +++ b/modules/xbmc-php-rpc/InvalidCommandException.php @@ -0,0 +1,7 @@ +name = $name; + $this->children = $children; + $this->client = $client; + $this->parentNamespace = $parent; + } + + /** + * Executes the called command. + * + * @param string $name The name of the command to call. + * @arguments mixed An array of arguments to be send with the remote + * procedure call. + * @return XBMC_RPC_Response The response of the remote procedure call. + * @exception XBMC_RPC_InvalidCommandException if the requested command does + * not exist in this namespace. + * @access public + */ + public function __call($name, array $arguments) { + $this->assertHasChildCommand($name); + if (empty($this->objectCache[$name])) { + $this->objectCache[$name] = new XBMC_RPC_Command($name, $this->client, $this); + } + return $this->objectCache[$name]->execute($arguments); + } + + /** + * Gets the requested child namespace. + * + * @param string $name The name of the namespace to get. + * @return XBMC_RPC_Namespace The requested child namespace. + * @exception XBMC_RPC_InvalidNamespaceException if the requested namespace does + * not exist in this namespace. + * @access public + */ + public function __get($name) { + $this->assertHasChildNamespace($name); + if (empty($this->objectCache[$name])) { + $this->objectCache[$name] = new XBMC_RPC_Namespace($name, $this->children[$name], $this->client, $this); + } + return $this->objectCache[$name]; + } + + /** + * Gets the full dot-delimited string representing the path from the root + * namespace to the current namespace. + * + * @return string The dot-delimited string. + * @access public + */ + public function getFullName() { + $name = ''; + if (!empty($this->parentNamespace)) { + $name = $this->parentNamespace->getFullName() . '.' . $this->name; + } + return trim($name, '.'); + } + + /** + * Asserts that the the namespace contains the specified command as a direct + * child. + * + * @param string $name The name of the command to check for. + * @exception XBMC_RPC_InvalidCommandException if the command is not a direct + * child of this namespace. + * @access private + */ + private function assertHasChildCommand($name) { + if (!$this->hasChildCommand($name)) { + throw new XBMC_RPC_InvalidCommandException("Command $name does not exist in namespace $this->name"); + } + } + + /** + * Asserts that the the namespace contains the specified namespace as a direct + * child. + * + * @param string $name The name of the namespace to check for. + * @exception XBMC_RPC_InvalidNamespaceException if the namespace is not a direct + * child of this namespace. + * @access private + */ + private function assertHasChildNamespace($name) { + if (!$this->hasChildNamespace($name)) { + throw new XBMC_RPC_InvalidNamespaceException("Namespace $name does not exist in namespace $this->name"); + } + } + + /** + * Checks if the namespace has the specified command as a direct child. + * + * @param string $name The name of the command to check for. + * @return bool True if the command exists in this namespace, false if not. + * @access private + */ + private function hasChildCommand($name) { + return in_array($name, $this->children); + } + + /** + * Checks if the namespace has the specified namespace as a direct child. + * + * @param string $name The name of the namespace to check for. + * @return bool True if the namespace exists in this namespace, false if not. + * @access private + */ + private function hasChildNamespace($name) { + return array_key_exists($name, $this->children); + } + +} \ No newline at end of file diff --git a/modules/xbmc-php-rpc/RequestException.php b/modules/xbmc-php-rpc/RequestException.php new file mode 100644 index 00000000..d0d704f9 --- /dev/null +++ b/modules/xbmc-php-rpc/RequestException.php @@ -0,0 +1,7 @@ +decodeResponse($response); + $this->id = $response['id']; + if (isset($response['error'])) { + throw new XBMC_RPC_ResponseException($response['error']['message'], $response['error']['code']); + } elseif (!isset($response['result'])) { + throw new XBMC_RPC_ResponseException('Invalid JSON RPC response'); + } + $this->data = $response['result']; + } + + /** + * Gets the response data. + * + * @return mixed The response data. + */ + public function getData() { + return $this->data; + } + + /** + * Gets the response id. + * + * @return string The response id. + */ + public function getId() { + return $this->id; + } + + /** + * Takes a JSON string as returned from the server and decodes it into an + * associative array. + */ + private function decodeResponse($json) { + if (extension_loaded('mbstring')) { + $encoding = mb_detect_encoding($json, 'ASCII,UTF-8,ISO-8859-1,windows-1252,iso-8859-15'); + if ($encoding && !in_array($encoding, array('UTF-8', 'ASCII'))) { + $json = mb_convert_encoding($json, 'UTF-8', $encoding); + } + } + return json_decode($json, true); + } + +} \ No newline at end of file diff --git a/modules/xbmc-php-rpc/ResponseException.php b/modules/xbmc-php-rpc/ResponseException.php new file mode 100644 index 00000000..b1d7eac1 --- /dev/null +++ b/modules/xbmc-php-rpc/ResponseException.php @@ -0,0 +1,7 @@ +parseParameters($parameters)) { + throw new XBMC_RPC_ServerException('Unable to parse server parameters'); + } + $this->parameters = $parameters; + } + + /** + * Checks if the server is connected. + * + * @return bool True if the server is connected, false if not. + * @access public + */ + public function isConnected() { + return $this->connected; + } + + /** + * Gets the connection parameters/ + * + * @return mixed The connection parameters as an associative array. + * @access public + */ + public function getParameters() { + return $this->parameters; + } + + /** + * Parses the supplied parameters into a standard associative array format. + * + * @param mixed $parameters An associative array of connection parameters, + * or a valid connection URI as a string. If supplying an array, the following + * paramters are accepted: host, port, user and pass. Any other parameters + * are discarded. + * @return mixed The connection parameters as an associative array, or false + * if the parameters could not be parsed. The array will have the following + * keys: host, port, user and pass. + * @access private + */ + private function parseParameters($parameters) { + + if (is_string($parameters)) { + $parameters = preg_replace('#^[a-z]+://#i', '', trim($parameters)); + if (!$parameters = parse_url('http://' . $parameters)) { + return false; + } + } + + if (!is_array($parameters)) { + // if parameters are not a string or an array, something is wrong + return false; + } + + $defaults = array( + 'host' => 'localhost', + 'port' => 8080, + 'user' => null, + 'pass' => null + ); + $parameters = array_intersect_key(array_merge($defaults, $parameters), $defaults); + return $parameters; + + } + +} \ No newline at end of file diff --git a/modules/xbmc-php-rpc/ServerException.php b/modules/xbmc-php-rpc/ServerException.php new file mode 100644 index 00000000..0ac0e65a --- /dev/null +++ b/modules/xbmc-php-rpc/ServerException.php @@ -0,0 +1,7 @@ +fp)) { + fclose($this->fp); + } + } + + /** + * Asserts that the server is reachable and a connection can be made. + * + * @return void + * @exception XBMC_RPC_ConnectionException if it is not possible to connect to + * the server. + * @access protected + */ + protected function assertCanConnect() { + if (!$this->canConnect()) { + throw new XBMC_RPC_ConnectionException('Unable to connect to XBMC server via TCP'); + } + } + + /** + * Prepares for a connection to XBMC via TCP. + * + * @return void + * @access protected + */ + protected function prepareConnection() { + $parameters = $this->server->getParameters(); + $this->fp = @fsockopen($parameters['host'], $parameters['port']); + } + + /** + * Sends a JSON-RPC request to XBMC and returns the result. + * + * @param string $json A JSON-encoded string representing the remote procedure call. + * This string should conform to the JSON-RPC 2.0 specification. + * @param string $rpcId The unique ID of the remote procedure call. + * @return string The JSON-encoded response string from the server. + * @exception XBMC_RPC_RequestException if it was not possible to make the request. + * @access protected + * @link http://groups.google.com/group/json-rpc/web/json-rpc-2-0 JSON-RPC 2.0 specification + */ + protected function sendRequest($json, $rpcId) { + $this->prepareConnection(); + if (!$this->canConnect()) { + throw new XBMC_RPC_ConnectionException('Lost connection to XBMC server'); + } + fwrite($this->fp, $json); + while (true) { + $result = $this->readJsonObject(); + if (strpos($result, '"id" : "' . $rpcId . '"') !== false) { + break; + } + if (strpos($result, '"id":"' . $rpcId . '"') !== false) { + break; + } + } + fclose($this->fp); + return $result; + } + + /** + * Checks if it is possible to connect to the server. + * + * @return bool True if it is possible to connect, false if not. + * @access private + */ + private function canConnect() { + return is_resource($this->fp); + } + + /** + * Reads a single JSON object from the socket and returns it. + * + * @return string The JSON object string from the server. + * @access private + */ + private function readJsonObject() { + + $open = $close = 0; + $escaping = false; + $quoteChar = null; + $result = ''; + + while (false !== ($char = fgetc($this->fp))) { + if (!$escaping) { + switch ($char) { + case "'": + case '"': + if (null === $quoteChar) { + $quoteChar = $char; + } elseif ($quoteChar == $char) { + $quoteChar = null; + } + break; + case '{': + if (null === $quoteChar) { + ++$open; + } + break; + case '}': + if (null === $quoteChar) { + ++$close; + } + break; + case '\\': + $escaping = true; + break; + } + } else { + $escaping = false; + } + $result .= $char; + if ($open == $close) { + break; + } + } + + return $result; + } + +} \ No newline at end of file