1<?php
2
3require_once 'ClientException.php';
4require_once 'ConnectionException.php';
5require_once 'RequestException.php';
6require_once 'ResponseException.php';
7require_once 'Server.php';
8require_once 'Namespace.php';
9require_once 'Response.php';
10
11abstract class XBMC_RPC_Client {
12
13    /**
14     * @var XBMC_RPC_Server A server object instance representing the server
15     * to be used for remote procedure calls.
16     * @access protected
17     */
18    protected $server;
19
20    /**
21     * @var XBMC_RPC_Namespace The root namespace instance.
22     * @access private
23     */
24    private $rootNamespace;
25
26    /**
27     * @var bool A flag to indicate if the JSON-RPC version is legacy, ie before
28     * the XBMC Eden updates. This can be used to determine the format of commands
29     * to be used with this library, allowing client code to support legacy systems.
30     * @access private
31     */
32    private $isLegacy = false;
33
34    /**
35     * Constructor.
36     *
37     * Connects to the server and populates a list of available commands by
38     * having the server introspect.
39     *
40     * @param mixed $parameters An associative array of connection parameters,
41     * or a valid connection URI as a string. If supplying an array, the following
42     * paramters are accepted: host, port, user and pass. Any other parameters
43     * are discarded.
44     * @exception XBMC_RPC_ConnectionException if it is not possible to connect to
45     * the server.
46     * @access public
47     */
48    public function __construct($parameters) {
49        try {
50            $server = new XBMC_RPC_Server($parameters);
51        } catch (XBMC_RPC_ServerException $e) {
52            throw new XBMC_RPC_ConnectionException($e->getMessage(), $e->getCode(), $e);
53        }
54        $this->server = $server;
55        $this->prepareConnection();
56        $this->assertCanConnect();
57        $this->createRootNamespace();
58    }
59
60    /**
61     * Delegates any direct Command calls to the root namespace.
62     *
63     * @param string $name The name of the called command.
64     * @param mixed $arguments An array of arguments used to call the command.
65     * @return The result of the command call as returned from the namespace.
66     * @exception XBMC_RPC_InvalidCommandException if the called command does not
67     * exist in the root namespace.
68     * @access public
69     */
70    public function __call($name, array $arguments) {
71        return call_user_func_array(array($this->rootNamespace, $name), $arguments);
72    }
73
74    /**
75     * Delegates namespace accesses to the root namespace.
76     *
77     * @param string $name The name of the requested namespace.
78     * @return XBMC_RPC_Namespace The requested namespace.
79     * @exception XBMC_RPC_InvalidNamespaceException if the namespace does not
80     * exist in the root namespace.
81     * @access public
82     */
83    public function __get($name) {
84        return $this->rootNamespace->$name;
85    }
86
87    /**
88     * Executes a remote procedure call using the supplied XBMC_RPC_Command
89     * object.
90     *
91     * @param XBMC_RPC_Command The command to execute.
92     * @return XBMC_RPC_Response The response from the remote procedure call.
93     * @access public
94     */
95    public function executeCommand(XBMC_RPC_Command $command) {
96        return $this->sendRpc($command->getFullName(), $command->getArguments());
97    }
98
99    /**
100     * Determines if the XBMC system to which the client is connected is legacy
101     * (pre Eden) or not. This is useful because the format of commands/params
102     * is different in the Eden RPC implementation.
103     *
104     * @return bool True if the system is legacy, false if not.
105     * @access public
106     */
107    public function isLegacy() {
108        return $this->isLegacy;
109    }
110
111    /**
112     * Asserts that the server is reachable and a connection can be made.
113     *
114     * @return void
115     * @exception XBMC_RPC_ConnectionException if it is not possible to connect to
116     * the server.
117     * @abstract
118     * @access protected
119     */
120    protected abstract function assertCanConnect();
121
122    /**
123     * Prepares for a connection to XBMC.
124     *
125     * Should be used by child classes for any pre-connection logic which is necessary.
126     *
127     * @return void
128     * @exception XBMC_RPC_ClientException if it was not possible to prepare for
129     * connection successfully.
130     * @abstract
131     * @access protected
132     */
133    protected abstract function prepareConnection();
134
135    /**
136     * Sends a JSON-RPC request to XBMC and returns the result.
137     *
138     * @param string $json A JSON-encoded string representing the remote procedure call.
139     * This string should conform to the JSON-RPC 2.0 specification.
140     * @param string $rpcId The unique ID of the remote procedure call.
141     * @return string The JSON-encoded response string from the server.
142     * @exception XBMC_RPC_RequestException if it was not possible to make the request.
143     * @access protected
144     * @link http://groups.google.com/group/json-rpc/web/json-rpc-2-0 JSON-RPC 2.0 specification
145     */
146    protected abstract function sendRequest($json, $rpcId);
147
148    /**
149     * Build a JSON-RPC 2.0 compatable json_encoded string representing the
150     * specified command, parameters and request id.
151     *
152     * @param string $command The name of the command to be called.
153     * @param mixed $params An array of paramters to be passed to the command.
154     * @param string $rpcId A unique string used for identifying the request.
155     * @access private
156     */
157    private function buildJson($command, $params, $rpcId) {
158        $data = array(
159            'jsonrpc' => '2.0',
160            'method' => $command,
161            'params' => $params,
162            'id' => $rpcId
163        );
164        return json_encode($data);
165    }
166
167    /**
168     * Ensures that the recieved response from a remote procedure call is valid.
169     *
170     * $param XBMC_RPC_Response $response A response object encapsulating remote
171     * procedure call response data as returned from Client::sendRequest().
172     * @return bool True of the reponse is valid, false if not.
173     * @access private
174     */
175    private function checkResponse(XBMC_RPC_Response $response, $rpcId) {
176        return ($response->getId() == $rpcId);
177    }
178
179    /**
180     * Creates the root namespace instance.
181     *
182     * @return void
183     * @access private
184     */
185    private function createRootNamespace() {
186        $commands = $this->loadAvailableCommands();
187        $this->rootNamespace = new XBMC_RPC_Namespace('root', $commands, $this);
188    }
189
190    /**
191     * Generates a unique string to be used as a remote procedure call ID.
192     *
193     * @return string A unique string.
194     * @access private
195     */
196    private function getRpcId() {
197        return uniqid();
198    }
199
200    /**
201     * Retrieves an array of commands by requesting the RPC server to introspect.
202     *
203     * @return mixed An array of available commands which may be executed on the server.
204     * @exception XBMC_RPC_RequestException if it is not possible to retrieve a list of
205     * available commands.
206     * @access private
207     */
208    private function loadAvailableCommands() {
209        try {
210            $response = $this->sendRpc('JSONRPC.Introspect');
211        } catch (XBMC_RPC_Exception $e) {
212            throw new XBMC_RPC_RequestException(
213                'Unable to retrieve list of available commands: ' . $e->getMessage()
214            );
215        }
216        if (isset($response['commands'])) {
217            $this->isLegacy = true;
218            return $this->loadAvailableCommandsLegacy($response);
219        }
220        $commands = array();
221        foreach (array_keys($response['methods']) as $command) {
222            $array = $this->commandStringToArray($command);
223            $commands = $this->mergeCommandArrays($commands, $array);
224        }
225        return $commands;
226    }
227
228    /**
229     * Retrieves an array of commands by requesting the RPC server to introspect.
230     *
231     * This method supports the legacy implementation of XBMC's RPC.
232     *
233     * @return mixed An array of available commands which may be executed on the server.
234     * @access private
235     */
236    private function loadAvailableCommandsLegacy($response) {
237        $commands = array();
238        foreach ($response['commands'] as $command) {
239            $array = $this->commandStringToArray($command['command']);
240            $commands = $this->mergeCommandArrays($commands, $array);
241        }
242        return $commands;
243    }
244
245    /**
246     * Converts a dot-delimited command name to a multidimensional array format.
247     *
248     * @return mixed An array representing the command.
249     * @access private
250     */
251    private function commandStringToArray($command) {
252        $path = explode('.', $command);
253        if (count($path) === 1) {
254            $commands[] = $path[0];
255            return array();
256        }
257        $command = array_pop($path);
258        $array = array();
259        $reference =& $array;
260        foreach ($path as $i => $key) {
261            if (is_numeric($key) && intval($key) > 0 || $key === '0') {
262                $key = intval($key);
263            }
264            if ($i === count($path) - 1) {
265                $reference[$key] = array($command);
266            } else {
267                if (!isset($reference[$key])) {
268                    $reference[$key] = array();
269                }
270                $reference =& $reference[$key];
271            }
272        }
273        return $array;
274    }
275
276    /**
277     * Recursively merges the supplied arrays whilst ensuring that commands are
278     * not duplicated within a namespace.
279     *
280     * Note that array_merge_recursive is not suitable here as it does not ensure
281     * that values are distinct within an array.
282     *
283     * @param mixed $base The base array into which $append will be merged.
284     * @param mixed $append The array to merge into $base.
285     * @return mixed The merged array of commands and namespaces.
286     * @access private
287     */
288    private function mergeCommandArrays(array $base, array $append) {
289        foreach ($append as $key => $value) {
290            if (!array_key_exists($key, $base) && !is_numeric($key)) {
291                $base[$key] = $append[$key];
292                continue;
293            }
294            if (is_array($value) || is_array($base[$key])) {
295                $base[$key] = $this->mergeCommandArrays($base[$key], $append[$key]);
296            } elseif (is_numeric($key)) {
297                if (!in_array($value, $base)) {
298                    $base[] = $value;
299                }
300            } else {
301                $base[$key] = $value;
302            }
303        }
304        return $base;
305    }
306
307    /**
308     * Executes a remote procedure call using the supplied command name and parameters.
309     *
310     * @param string $command The full, dot-delimited name of the command to call.
311     * @param mixed $params An array of parameters to be passed to the called method.
312     * @return mixed The data returned from the response.
313     * @exception XBMC_RPC_RequestException if it was not possible to make the request.
314     * @exception XBMC_RPC_ResponseException if the response was not being properly received.
315     * @access private
316     */
317    private function sendRpc($command, $params = array()) {
318        $rpcId = $this->getRpcId();
319        $json = $this->buildJson($command, $params, $rpcId);
320        $response = new XBMC_RPC_Response($this->sendRequest($json, $rpcId));
321        if (!$this->checkResponse($response, $rpcId)) {
322            throw new XBMC_RPC_ResponseException('JSON RPC request/response ID mismatch');
323        }
324        return $response->getData();
325    }
326
327}
328