2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.plex.internal.handler;
15 import java.io.IOException;
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;
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;
54 import com.google.gson.Gson;
55 import com.thoughtworks.xstream.XStream;
56 import com.thoughtworks.xstream.io.xml.StaxDriver;
59 * The {@link PlexApiConnector} is responsible for communications with the PLEX server
61 * @author Brian Homeyer - Initial contribution
62 * @author Aron Beurskens - Binding development
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();
75 private final Logger logger = LoggerFactory.getLogger(PlexApiConnector.class);
76 private @Nullable PlexUpdateListener listener;
78 private final XStream xStream = new XStream(new StaxDriver());
79 private Gson gson = new Gson();
80 private boolean isShutDown = false;
82 private @Nullable ScheduledFuture<?> socketReconnect;
83 private ScheduledExecutorService scheduler;
84 private @Nullable URI uri;
86 private String username = "";
87 private String password = "";
88 private String token = "";
89 private String host = "";
91 private static String scheme = "";
93 public PlexApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
94 this.scheduler = scheduler;
95 this.httpClient = httpClient;
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();
109 private String getSchemeWS() {
110 return "http".equals(scheme) ? "ws" : "wss";
113 public boolean hasToken() {
114 return !token.isBlank();
118 * Base configuration for XStream
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);
130 * Fetch the XML data and parse it through xStream to get a MediaContainer object
134 public @Nullable MediaContainer getSessionData() {
136 String url = "https://" + host + ":" + String.valueOf(port) + "/status/sessions" + "?X-Plex-Token=" + token;
137 logger.debug("Getting session data '{}'", url);
138 MediaContainer mediaContainer = getFromXml(doHttpRequest("GET", url, getClientHeaders(), false),
139 MediaContainer.class);
140 return mediaContainer;
141 } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
142 logger.debug("An exception occurred while polling the PLEX Server: '{}'", e.getMessage());
148 * Assemble the URL to include the Token
150 * @param url The url portion that is returned from the sessions call
151 * @return the completed url that will be usable
153 public String getURL(String url) {
154 String artURL = scheme + "://" + host + ":" + String.valueOf(port + url + "?X-Plex-Token=" + token);
159 * This method will get an X-Token from the PLEX.tv server if one is not provided in the bridge config
160 * and use this in the communication with the plex server
162 public void getToken() {
164 String authString = Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
165 Properties headers = getClientHeaders();
166 headers.put("Authorization", "Basic " + authString);
169 user = getFromXml(doHttpRequest("POST", SIGNIN_URL, headers, true), User.class);
170 } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
171 logger.debug("An exception occurred while fetching PLEX user token :'{}'", e.getMessage(), e);
172 throw new ConfigurationException("Error occurred while fetching PLEX user token, please check config");
175 if (user.getAuthenticationToken() != null) {
176 token = user.getAuthenticationToken();
177 logger.debug("PLEX login successful using username/password");
179 throw new ConfigurationException("Invalid credentials for PLEX account, please check config");
184 * This method will get the Api information from the PLEX.tv servers.
186 public boolean getApi() {
188 MediaContainer api = getFromXml(doHttpRequest("GET", API_URL, getClientHeaders(), true),
189 MediaContainer.class);
190 logger.debug("MediaContainer {}", api.getSize());
191 if (api.getDevice() != null) {
192 for (Device tmpDevice : api.getDevice()) {
193 if (tmpDevice.getConnection() != null) {
194 for (Connection tmpConn : tmpDevice.getConnection()) {
195 if (host.equals(tmpConn.getAddress())) {
196 scheme = tmpConn.getProtocol();
198 "PLEX Api fetched. Found configured PLEX server in Api request, applied. Protocol used : {}",
207 } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
208 logger.debug("An exception occurred while fetching API :'{}'", e.getMessage(), e);
214 * Make an HTTP request and return the response as a string.
216 * @param method GET/POST
217 * @param url What URL to call
218 * @param headers Additional headers that will be used
219 * @param verify Flag to indicate if ssl certificate checking should be done
220 * @return Returns a string of the http response
221 * @throws IOException
222 * @throws ExecutionException
223 * @throws TimeoutException
224 * @throws InterruptedException
226 private String doHttpRequest(String method, String url, Properties headers, boolean verify)
227 throws IOException, InterruptedException, TimeoutException, ExecutionException {
228 final String response;
230 // Requests sent to the PLEX.tv servers should use certificate checking
231 response = HttpUtil.executeUrl(method, url, headers, null, null, REQUEST_TIMEOUT_MS);
233 // Requests sent to the local server need to bypass certificate checking via the custom httpClient
234 final Request request = httpClient.newRequest(url).method(HttpUtil.createHttpMethod(method));
235 for (String httpHeaderKey : headers.stringPropertyNames()) {
236 if (httpHeaderKey.equalsIgnoreCase(HttpHeader.USER_AGENT.toString())) {
237 request.agent(headers.getProperty(httpHeaderKey));
239 request.header(httpHeaderKey, headers.getProperty(httpHeaderKey));
242 final ContentResponse res = request.send();
243 response = res.getContentAsString();
245 logger.debug("HTTP response: {}", response);
250 * Return the class object that was represented by the xml input string.
252 * @param response The xml response to parse
253 * @param type Class type for the XML parsing
254 * @return Returns a class object from the input sring
256 private <T> T getFromXml(String response, Class<T> type) {
257 @SuppressWarnings("unchecked")
258 T obj = (T) xStream.fromXML(response);
263 * Fills in the header information for any calls to PLEX services
265 * @return Property headers
267 private Properties getClientHeaders() {
268 Properties headers = new Properties();
269 headers.put(HttpHeader.USER_AGENT, "openHAB/" + org.openhab.core.OpenHAB.getVersion() + " PLEX binding");
270 headers.put("X-Plex-Client-Identifier", CLIENT_ID);
271 headers.put("X-Plex-Product", "openHAB");
272 headers.put("X-Plex-Version", "");
273 headers.put("X-Plex-Device", "JRE11");
274 headers.put("X-Plex-Device-Name", "openHAB");
275 headers.put("X-Plex-Provides", "controller");
276 headers.put("X-Plex-Platform", "Java");
277 headers.put("X-Plex-Platform-Version", "JRE11");
279 headers.put(TOKEN_HEADER, token);
285 * Register callback to PlexServerHandler
287 * @param listener function to call
289 public void registerListener(PlexUpdateListener listener) {
290 this.listener = listener;
294 * Dispose method, cleans up the websocket starts the reconnect logic
296 public void dispose() {
300 ScheduledFuture<?> socketReconnect = this.socketReconnect;
301 if (socketReconnect != null) {
302 socketReconnect.cancel(true);
303 this.socketReconnect = null;
306 } catch (Exception e) {
307 logger.debug("Could not stop webSocketClient, message {}", e.getMessage());
312 * Connect to the websocket
314 public void connect() {
315 logger.debug("Connecting to WebSocket");
317 wsClient = new WebSocketClient(httpClient);
318 uri = new URI(getSchemeWS() + "://" + host + ":32400/:/websockets/notifications?X-Plex-Token=" + token); // WS_ENDPOINT_TOUCHWAND);
319 } catch (URISyntaxException e) {
320 logger.debug("URI not valid {} message {}", uri, e.getMessage());
323 wsClient.setConnectTimeout(2000);
324 ClientUpgradeRequest request = new ClientUpgradeRequest();
328 wsClient.connect(plexSocket, uri, request);
329 } catch (Exception e) {
330 logger.debug("Could not connect webSocket URI {} message {}", uri, e.getMessage(), e);
335 * PlexSocket class to handle the websocket connection to the PLEX server
337 @WebSocket(maxIdleTime = 360000) // WEBSOCKET_IDLE_TIMEOUT_MS)
338 public class PlexSocket {
340 public void onClose(int statusCode, String reason) {
341 logger.debug("Connection closed: {} - {}", statusCode, reason);
343 logger.debug("PLEX websocket closed - reconnecting");
349 public void onConnect(Session session) {
350 logger.debug("PLEX Socket connected to {}", session.getRemoteAddress().getAddress());
354 public void onMessage(String msg) {
355 NotificationContainer notification = gson.fromJson(msg, NotificationContainer.class);
356 if (notification != null) {
357 PlexUpdateListener listenerLocal = listener;
358 if (listenerLocal != null && notification.getNotificationContainer().getType().equals("playing")) {
359 listenerLocal.onItemStatusUpdate(
360 notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
362 notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
369 public void onError(Throwable cause) {
371 logger.debug("WebSocket onError - reconnecting");
376 private void asyncWeb() {
377 ScheduledFuture<?> mySocketReconnect = socketReconnect;
378 if (mySocketReconnect == null || mySocketReconnect.isDone()) {
379 socketReconnect = scheduler.schedule(PlexApiConnector.this::connect, 5, TimeUnit.SECONDS); // WEBSOCKET_RECONNECT_INTERVAL_SEC,
385 * Handles control commands to the plex player.
391 * @param command The control command
392 * @param playerID The ID of the PLEX player
394 public void controlPlayer(Command command, String playerID) {
395 String commandPath = null;
396 if (command instanceof PlayPauseType) {
397 if (command.equals(PlayPauseType.PLAY)) {
398 commandPath = "/player/playback/play";
400 if (command.equals(PlayPauseType.PAUSE)) {
401 commandPath = "/player/playback/pause";
405 if (command instanceof NextPreviousType) {
406 if (command.equals(NextPreviousType.PREVIOUS)) {
407 commandPath = "/player/playback/skipPrevious";
409 if (command.equals(NextPreviousType.NEXT)) {
410 commandPath = "/player/playback/skipNext";
414 if (commandPath != null) {
416 String url = "https://" + host + ":" + String.valueOf(port) + commandPath;
417 Properties headers = getClientHeaders();
418 headers.put("X-Plex-Target-Client-Identifier", playerID);
419 doHttpRequest("GET", url, headers, false);
420 } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
421 logger.debug("An exception occurred trying to send command '{}' to the player: {}", commandPath,
425 logger.warn("Could not match command '{}' to an action", command);