]> git.basschouten.com Git - openhab-addons.git/blob
23d79a2059478b6ffe9ec2348baa8c4b8743407c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.plex.internal.handler;
14
15 import java.io.IOException;
16 import java.net.URI;
17 import java.net.URISyntaxException;
18 import java.util.Base64;
19 import java.util.Properties;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.ScheduledExecutorService;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.http.HttpHeader;
32 import org.eclipse.jetty.websocket.api.Session;
33 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
34 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
35 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
36 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
37 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
38 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
39 import org.eclipse.jetty.websocket.client.WebSocketClient;
40 import org.openhab.binding.plex.internal.config.PlexServerConfiguration;
41 import org.openhab.binding.plex.internal.dto.MediaContainer;
42 import org.openhab.binding.plex.internal.dto.MediaContainer.Device;
43 import org.openhab.binding.plex.internal.dto.MediaContainer.Device.Connection;
44 import org.openhab.binding.plex.internal.dto.NotificationContainer;
45 import org.openhab.binding.plex.internal.dto.User;
46 import org.openhab.core.i18n.ConfigurationException;
47 import org.openhab.core.io.net.http.HttpUtil;
48 import org.openhab.core.library.types.NextPreviousType;
49 import org.openhab.core.library.types.PlayPauseType;
50 import org.openhab.core.types.Command;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 import com.google.gson.Gson;
55 import com.thoughtworks.xstream.XStream;
56 import com.thoughtworks.xstream.io.xml.StaxDriver;
57
58 /**
59  * The {@link PlexApiConnector} is responsible for communications with the PLEX server
60  *
61  * @author Brian Homeyer - Initial contribution
62  * @author Aron Beurskens - Binding development
63  */
64 @NonNullByDefault
65 public class PlexApiConnector {
66     private static final int REQUEST_TIMEOUT_MS = 2000;
67     private static final String TOKEN_HEADER = "X-Plex-Token";
68     private static final String SIGNIN_URL = "https://plex.tv/users/sign_in.xml";
69     private static final String CLIENT_ID = "928dcjhd-91ka-la91-md7a-0msnan214563";
70     private static final String API_URL = "https://plex.tv/api/resources?includeHttps=1";
71     private final HttpClient httpClient;
72     private WebSocketClient wsClient = new WebSocketClient();
73     private PlexSocket plexSocket = new PlexSocket();
74
75     private final Logger logger = LoggerFactory.getLogger(PlexApiConnector.class);
76     private @Nullable PlexUpdateListener listener;
77
78     private final XStream xStream = new XStream(new StaxDriver());
79     private Gson gson = new Gson();
80     private boolean isShutDown = false;
81
82     private @Nullable ScheduledFuture<?> socketReconnect;
83     private ScheduledExecutorService scheduler;
84     private @Nullable URI uri;
85
86     private String username = "";
87     private String password = "";
88     private String token = "";
89     private String host = "";
90     private int port = 0;
91     private static String scheme = "";
92
93     public PlexApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
94         this.scheduler = scheduler;
95         this.httpClient = httpClient;
96         setupXstream();
97     }
98
99     public void setParameters(PlexServerConfiguration connProps) {
100         username = connProps.username;
101         password = connProps.password;
102         token = connProps.token;
103         host = connProps.host;
104         port = connProps.portNumber;
105         wsClient = new WebSocketClient();
106         plexSocket = new PlexSocket();
107     }
108
109     private String getSchemeWS() {
110         return "http".equals(scheme) ? "ws" : "wss";
111     }
112
113     public boolean hasToken() {
114         return !token.isBlank();
115     }
116
117     /**
118      * Base configuration for XStream
119      */
120     private void setupXstream() {
121         xStream.allowTypesByWildcard(
122                 new String[] { User.class.getPackageName() + ".**", MediaContainer.class.getPackageName() + ".**" });
123         xStream.setClassLoader(PlexApiConnector.class.getClassLoader());
124         xStream.ignoreUnknownElements();
125         xStream.processAnnotations(User.class);
126         xStream.processAnnotations(MediaContainer.class);
127     }
128
129     /**
130      * Fetch the XML data and parse it through xStream to get a MediaContainer object
131      *
132      * @return
133      */
134     public @Nullable MediaContainer getSessionData() {
135         try {
136             String url = "https://" + host + ":" + port + "/status/sessions" + "?X-Plex-Token=" + token;
137             logger.debug("Getting session data '{}'", url);
138             return getFromXml(doHttpRequest("GET", url, getClientHeaders(), false), MediaContainer.class);
139         } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
140             logger.debug("An exception occurred while polling the PLEX Server: '{}'", e.getMessage());
141             return null;
142         }
143     }
144
145     /**
146      * Assemble the URL to include the Token
147      *
148      * @param url The url portion that is returned from the sessions call
149      * @return the completed url that will be usable
150      */
151     public String getURL(String url) {
152         return scheme + "://" + host + ":" + port + url + "?X-Plex-Token=" + token;
153     }
154
155     /**
156      * This method will get an X-Token from the PLEX.tv server if one is not provided in the bridge config
157      * and use this in the communication with the plex server
158      */
159     public void getToken() {
160         User user;
161         String authString = Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
162         Properties headers = getClientHeaders();
163         headers.put("Authorization", "Basic " + authString);
164
165         try {
166             user = getFromXml(doHttpRequest("POST", SIGNIN_URL, headers, true), User.class);
167         } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
168             logger.debug("An exception occurred while fetching PLEX user token :'{}'", e.getMessage(), e);
169             throw new ConfigurationException("Error occurred while fetching PLEX user token, please check config");
170         }
171
172         if (user.getAuthenticationToken() != null) {
173             token = user.getAuthenticationToken();
174             logger.debug("PLEX login successful using username/password");
175         } else {
176             throw new ConfigurationException("Invalid credentials for PLEX account, please check config");
177         }
178     }
179
180     /**
181      * This method will get the Api information from the PLEX.tv servers.
182      */
183     public boolean getApi() {
184         try {
185             MediaContainer api = getFromXml(doHttpRequest("GET", API_URL, getClientHeaders(), true),
186                     MediaContainer.class);
187             logger.debug("MediaContainer {}", api.getSize());
188             if (api.getDevice() != null) {
189                 for (Device tmpDevice : api.getDevice()) {
190                     if (tmpDevice.getConnection() != null) {
191                         for (Connection tmpConn : tmpDevice.getConnection()) {
192                             if (host.equals(tmpConn.getAddress())) {
193                                 scheme = tmpConn.getProtocol();
194                                 logger.debug(
195                                         "PLEX Api fetched. Found configured PLEX server in Api request, applied. Protocol used : {}",
196                                         scheme);
197                                 return true;
198                             }
199                         }
200                     }
201                 }
202             }
203             return false;
204         } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
205             logger.debug("An exception occurred while fetching API :'{}'", e.getMessage(), e);
206         }
207         return false;
208     }
209
210     /**
211      * Make an HTTP request and return the response as a string.
212      *
213      * @param method GET/POST
214      * @param url What URL to call
215      * @param headers Additional headers that will be used
216      * @param verify Flag to indicate if ssl certificate checking should be done
217      * @return Returns a string of the http response
218      * @throws IOException
219      * @throws ExecutionException
220      * @throws TimeoutException
221      * @throws InterruptedException
222      */
223     private String doHttpRequest(String method, String url, Properties headers, boolean verify)
224             throws IOException, InterruptedException, TimeoutException, ExecutionException {
225         final String response;
226         if (verify) {
227             // Requests sent to the PLEX.tv servers should use certificate checking
228             response = HttpUtil.executeUrl(method, url, headers, null, null, REQUEST_TIMEOUT_MS);
229         } else {
230             // Requests sent to the local server need to bypass certificate checking via the custom httpClient
231             final Request request = httpClient.newRequest(url).method(HttpUtil.createHttpMethod(method))
232                     .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
233             for (String httpHeaderKey : headers.stringPropertyNames()) {
234                 if (httpHeaderKey.equalsIgnoreCase(HttpHeader.USER_AGENT.toString())) {
235                     request.agent(headers.getProperty(httpHeaderKey));
236                 } else {
237                     request.header(httpHeaderKey, headers.getProperty(httpHeaderKey));
238                 }
239             }
240             final ContentResponse res = request.send();
241             response = res.getContentAsString();
242         }
243         logger.debug("HTTP response: {}", response);
244         return response;
245     }
246
247     /**
248      * Return the class object that was represented by the xml input string.
249      *
250      * @param response The xml response to parse
251      * @param type Class type for the XML parsing
252      * @return Returns a class object from the input sring
253      */
254     private <T> T getFromXml(String response, Class<T> type) {
255         @SuppressWarnings("unchecked")
256         T obj = (T) xStream.fromXML(response);
257         return obj;
258     }
259
260     /**
261      * Fills in the header information for any calls to PLEX services
262      *
263      * @return Property headers
264      */
265     private Properties getClientHeaders() {
266         Properties headers = new Properties();
267         headers.put(HttpHeader.USER_AGENT, "openHAB/" + org.openhab.core.OpenHAB.getVersion() + " PLEX binding");
268         headers.put("X-Plex-Client-Identifier", CLIENT_ID);
269         headers.put("X-Plex-Product", "openHAB");
270         headers.put("X-Plex-Version", "");
271         headers.put("X-Plex-Device", "JRE11");
272         headers.put("X-Plex-Device-Name", "openHAB");
273         headers.put("X-Plex-Provides", "controller");
274         headers.put("X-Plex-Platform", "Java");
275         headers.put("X-Plex-Platform-Version", "JRE11");
276         if (hasToken()) {
277             headers.put(TOKEN_HEADER, token);
278         }
279         return headers;
280     }
281
282     /**
283      * Register callback to PlexServerHandler
284      *
285      * @param listener function to call
286      */
287     public void registerListener(PlexUpdateListener listener) {
288         this.listener = listener;
289     }
290
291     /**
292      * Dispose method, cleans up the websocket starts the reconnect logic
293      */
294     public void dispose() {
295         isShutDown = true;
296         try {
297             wsClient.stop();
298             ScheduledFuture<?> socketReconnect = this.socketReconnect;
299             if (socketReconnect != null) {
300                 socketReconnect.cancel(true);
301                 this.socketReconnect = null;
302             }
303             httpClient.stop();
304         } catch (Exception e) {
305             logger.debug("Could not stop webSocketClient,  message {}", e.getMessage());
306         }
307     }
308
309     /**
310      * Connect to the websocket
311      */
312     public void connect() {
313         logger.debug("Connecting to WebSocket");
314         try {
315             wsClient = new WebSocketClient(httpClient);
316             uri = new URI(getSchemeWS() + "://" + host + ":32400/:/websockets/notifications?X-Plex-Token=" + token); // WS_ENDPOINT_TOUCHWAND);
317         } catch (URISyntaxException e) {
318             logger.debug("URI not valid {} message {}", uri, e.getMessage());
319             return;
320         }
321         wsClient.setConnectTimeout(2000);
322         ClientUpgradeRequest request = new ClientUpgradeRequest();
323         try {
324             isShutDown = false;
325             wsClient.start();
326             wsClient.connect(plexSocket, uri, request);
327         } catch (Exception e) {
328             logger.debug("Could not connect webSocket URI {} message {}", uri, e.getMessage(), e);
329         }
330     }
331
332     /**
333      * PlexSocket class to handle the websocket connection to the PLEX server
334      */
335     @WebSocket(maxIdleTime = 360000) // WEBSOCKET_IDLE_TIMEOUT_MS)
336     public class PlexSocket {
337         @OnWebSocketClose
338         public void onClose(int statusCode, String reason) {
339             logger.debug("Connection closed: {} - {}", statusCode, reason);
340             if (!isShutDown) {
341                 logger.debug("PLEX websocket closed - reconnecting");
342                 asyncWeb();
343             }
344         }
345
346         @OnWebSocketConnect
347         public void onConnect(Session session) {
348             logger.debug("PLEX Socket connected to {}", session.getRemoteAddress().getAddress());
349         }
350
351         @OnWebSocketMessage
352         public void onMessage(String msg) {
353             NotificationContainer notification = gson.fromJson(msg, NotificationContainer.class);
354             if (notification != null) {
355                 PlexUpdateListener listenerLocal = listener;
356                 if (listenerLocal != null && "playing".equals(notification.getNotificationContainer().getType())) {
357                     listenerLocal.onItemStatusUpdate(
358                             notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
359                                     .getSessionKey(),
360                             notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
361                                     .getState());
362                 }
363             }
364         }
365
366         @OnWebSocketError
367         public void onError(Throwable cause) {
368             if (!isShutDown) {
369                 logger.debug("WebSocket onError - reconnecting");
370                 asyncWeb();
371             }
372         }
373
374         private void asyncWeb() {
375             ScheduledFuture<?> mySocketReconnect = socketReconnect;
376             if (mySocketReconnect == null || mySocketReconnect.isDone()) {
377                 socketReconnect = scheduler.schedule(PlexApiConnector.this::connect, 5, TimeUnit.SECONDS); // WEBSOCKET_RECONNECT_INTERVAL_SEC,
378             }
379         }
380     }
381
382     /**
383      * Handles control commands to the plex player.
384      *
385      * Supports:
386      * - Play / Pause
387      * - Previous / Next
388      *
389      * @param command The control command
390      * @param playerID The ID of the PLEX player
391      */
392     public void controlPlayer(Command command, String playerID) {
393         String commandPath = null;
394         if (command instanceof PlayPauseType) {
395             if (command.equals(PlayPauseType.PLAY)) {
396                 commandPath = "/player/playback/play";
397             }
398             if (command.equals(PlayPauseType.PAUSE)) {
399                 commandPath = "/player/playback/pause";
400             }
401         }
402
403         if (command instanceof NextPreviousType) {
404             if (command.equals(NextPreviousType.PREVIOUS)) {
405                 commandPath = "/player/playback/skipPrevious";
406             }
407             if (command.equals(NextPreviousType.NEXT)) {
408                 commandPath = "/player/playback/skipNext";
409             }
410         }
411
412         if (commandPath != null) {
413             try {
414                 String url = "https://" + host + ":" + port + commandPath;
415                 Properties headers = getClientHeaders();
416                 headers.put("X-Plex-Target-Client-Identifier", playerID);
417                 doHttpRequest("GET", url, headers, false);
418             } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
419                 logger.debug("An exception occurred trying to send command '{}' to the player: {}", commandPath,
420                         e.getMessage());
421             }
422         } else {
423             logger.warn("Could not match command '{}' to an action", command);
424         }
425     }
426 }