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.ScheduledExecutorService;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.eclipse.jetty.http.HttpHeader;
28 import org.eclipse.jetty.websocket.api.Session;
29 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
30 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
31 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
32 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
33 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
34 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
35 import org.eclipse.jetty.websocket.client.WebSocketClient;
36 import org.openhab.binding.plex.internal.config.PlexServerConfiguration;
37 import org.openhab.binding.plex.internal.dto.MediaContainer;
38 import org.openhab.binding.plex.internal.dto.MediaContainer.Device;
39 import org.openhab.binding.plex.internal.dto.MediaContainer.Device.Connection;
40 import org.openhab.binding.plex.internal.dto.NotificationContainer;
41 import org.openhab.binding.plex.internal.dto.User;
42 import org.openhab.core.i18n.ConfigurationException;
43 import org.openhab.core.io.net.http.HttpUtil;
44 import org.openhab.core.library.types.NextPreviousType;
45 import org.openhab.core.library.types.PlayPauseType;
46 import org.openhab.core.types.Command;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import com.google.gson.Gson;
51 import com.thoughtworks.xstream.XStream;
52 import com.thoughtworks.xstream.io.xml.StaxDriver;
55 * The {@link PlexApiConnector} is responsible for communications with the PLEX server
57 * @author Brian Homeyer - Initial contribution
58 * @author Aron Beurskens - Binding development
61 public class PlexApiConnector {
62 private static final int REQUEST_TIMEOUT_MS = 2000;
63 private static final String TOKEN_HEADER = "X-Plex-Token";
64 private static final String SIGNIN_URL = "https://plex.tv/users/sign_in.xml";
65 private static final String CLIENT_ID = "928dcjhd-91ka-la91-md7a-0msnan214563";
66 private static final String API_URL = "https://plex.tv/api/resources?includeHttps=1";
67 private final HttpClient httpClient;
68 private WebSocketClient wsClient = new WebSocketClient();
69 private PlexSocket plexSocket = new PlexSocket();
71 private final Logger logger = LoggerFactory.getLogger(PlexApiConnector.class);
72 private @Nullable PlexUpdateListener listener;
74 private final XStream xStream = new XStream(new StaxDriver());
75 private Gson gson = new Gson();
76 private boolean isShutDown = false;
78 private @Nullable ScheduledFuture<?> socketReconnect;
79 private ScheduledExecutorService scheduler;
80 private @Nullable URI uri;
82 private String username = "";
83 private String password = "";
84 private String token = "";
85 private String host = "";
87 private static String scheme = "";
89 public PlexApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
90 this.scheduler = scheduler;
91 this.httpClient = httpClient;
95 public void setParameters(PlexServerConfiguration connProps) {
96 username = connProps.username;
97 password = connProps.password;
98 token = connProps.token;
99 host = connProps.host;
100 port = connProps.portNumber;
101 wsClient = new WebSocketClient();
102 plexSocket = new PlexSocket();
105 private String getSchemeWS() {
106 return "http".equals(scheme) ? "ws" : "wss";
109 public boolean hasToken() {
110 return !token.isBlank();
114 * Base configuration for XStream
116 private void setupXstream() {
117 xStream.allowTypesByWildcard(
118 new String[] { User.class.getPackageName() + ".**", MediaContainer.class.getPackageName() + ".**" });
119 xStream.setClassLoader(PlexApiConnector.class.getClassLoader());
120 xStream.ignoreUnknownElements();
121 xStream.processAnnotations(User.class);
122 xStream.processAnnotations(MediaContainer.class);
126 * Fetch the XML data and parse it through xStream to get a MediaContainer object
130 public @Nullable MediaContainer getSessionData() {
132 String url = "http://" + host + ":" + String.valueOf(port) + "/status/sessions" + "?X-Plex-Token=" + token;
133 logger.debug("Getting session data '{}'", url);
134 MediaContainer mediaContainer = doHttpRequest("GET", url, getClientHeaders(), MediaContainer.class);
135 return mediaContainer;
136 } catch (IOException e) {
137 logger.debug("An exception occurred while polling the PLEX Server: '{}'", e.getMessage());
143 * Assemble the URL to include the Token
145 * @param url The url portion that is returned from the sessions call
146 * @return the completed url that will be usable
148 public String getURL(String url) {
149 String artURL = scheme + "://" + host + ":" + String.valueOf(port + url + "?X-Plex-Token=" + token);
154 * This method will get an X-Token from the PLEX server if one is not provided in the bridge config
155 * and use this in the communication with the plex server
157 public void getToken() {
159 String authString = Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
160 Properties headers = getClientHeaders();
161 headers.put("Authorization", "Basic " + authString);
164 user = doHttpRequest("POST", SIGNIN_URL, headers, User.class);
165 } catch (IOException e) {
166 logger.debug("An exception occurred while fetching PLEX user token :'{}'", e.getMessage(), e);
167 throw new ConfigurationException("Error occurred while fetching PLEX user token, please check config");
170 if (user.getAuthenticationToken() != null) {
171 token = user.getAuthenticationToken();
172 logger.debug("PLEX login successful using username/password");
174 throw new ConfigurationException("Invalid credentials for PLEX account, please check config");
179 * This method will get the Api information from the PLEX servers.
181 public boolean getApi() {
183 MediaContainer api = doHttpRequest("GET", API_URL, getClientHeaders(), MediaContainer.class);
184 logger.debug("MediaContainer {}", api.getSize());
185 if (api.getDevice() != null) {
186 for (Device tmpDevice : api.getDevice()) {
187 if (tmpDevice.getConnection() != null) {
188 for (Connection tmpConn : tmpDevice.getConnection()) {
189 if (host.equals(tmpConn.getAddress())) {
190 scheme = tmpConn.getProtocol();
192 "PLEX Api fetched. Found configured PLEX server in Api request, applied. Protocol used : {}",
201 } catch (IOException e) {
202 logger.debug("An exception occurred while fetching API :'{}'", e.getMessage(), e);
208 * Make an HTTP request and return the class object that was used when calling.
210 * @param <T> Class being used(dto)
211 * @param method GET/POST
212 * @param url What URL to call
213 * @param headers Additional headers that will be used
214 * @param type class type for the XML parsing
215 * @return Returns a class object from the data returned by the call
216 * @throws IOException
218 private <T> T doHttpRequest(String method, String url, Properties headers, Class<T> type) throws IOException {
219 String response = HttpUtil.executeUrl(method, url, headers, null, null, REQUEST_TIMEOUT_MS);
220 @SuppressWarnings("unchecked")
221 T obj = (T) xStream.fromXML(response);
222 logger.debug("HTTP response {}", response);
227 * Fills in the header information for any calls to PLEX services
229 * @return Property headers
231 private Properties getClientHeaders() {
232 Properties headers = new Properties();
233 headers.put(HttpHeader.USER_AGENT, "openHAB / PLEX binding "); // + VERSION);
234 headers.put("X-Plex-Client-Identifier", CLIENT_ID);
235 headers.put("X-Plex-Product", "openHAB");
236 headers.put("X-Plex-Version", "");
237 headers.put("X-Plex-Device", "JRE11");
238 headers.put("X-Plex-Device-Name", "openHAB");
239 headers.put("X-Plex-Provides", "controller");
240 headers.put("X-Plex-Platform", "Java");
241 headers.put("X-Plex-Platform-Version", "JRE11");
243 headers.put(TOKEN_HEADER, token);
249 * Register callback to PlexServerHandler
251 * @param listener function to call
253 public void registerListener(PlexUpdateListener listener) {
254 this.listener = listener;
258 * Dispose method, cleans up the websocket starts the reconnect logic
260 public void dispose() {
264 ScheduledFuture<?> socketReconnect = this.socketReconnect;
265 if (socketReconnect != null) {
266 socketReconnect.cancel(true);
267 this.socketReconnect = null;
270 } catch (Exception e) {
271 logger.debug("Could not stop webSocketClient, message {}", e.getMessage());
276 * Connect to the websocket
278 public void connect() {
279 logger.debug("Connecting to WebSocket");
281 wsClient = new WebSocketClient(httpClient);
282 uri = new URI(getSchemeWS() + "://" + host + ":32400/:/websockets/notifications?X-Plex-Token=" + token); // WS_ENDPOINT_TOUCHWAND);
283 } catch (URISyntaxException e) {
284 logger.debug("URI not valid {} message {}", uri, e.getMessage());
287 wsClient.setConnectTimeout(2000);
288 ClientUpgradeRequest request = new ClientUpgradeRequest();
292 wsClient.connect(plexSocket, uri, request);
293 } catch (Exception e) {
294 logger.debug("Could not connect webSocket URI {} message {}", uri, e.getMessage(), e);
299 * PlexSocket class to handle the websocket connection to the PLEX server
301 @WebSocket(maxIdleTime = 360000) // WEBSOCKET_IDLE_TIMEOUT_MS)
302 public class PlexSocket {
304 public void onClose(int statusCode, String reason) {
305 logger.debug("Connection closed: {} - {}", statusCode, reason);
307 logger.debug("PLEX websocket closed - reconnecting");
313 public void onConnect(Session session) {
314 logger.debug("PLEX Socket connected to {}", session.getRemoteAddress().getAddress());
318 public void onMessage(String msg) {
319 NotificationContainer notification = gson.fromJson(msg, NotificationContainer.class);
320 if (notification != null) {
321 PlexUpdateListener listenerLocal = listener;
322 if (listenerLocal != null && notification.getNotificationContainer().getType().equals("playing")) {
323 listenerLocal.onItemStatusUpdate(
324 notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
326 notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
333 public void onError(Throwable cause) {
335 logger.debug("WebSocket onError - reconnecting");
340 private void asyncWeb() {
341 ScheduledFuture<?> mySocketReconnect = socketReconnect;
342 if (mySocketReconnect == null || mySocketReconnect.isDone()) {
343 socketReconnect = scheduler.schedule(PlexApiConnector.this::connect, 5, TimeUnit.SECONDS); // WEBSOCKET_RECONNECT_INTERVAL_SEC,
349 * Handles control commands to the plex player.
355 * @param command The control command
356 * @param playerID The ID of the PLEX player
358 public void controlPlayer(Command command, String playerID) {
359 String commandPath = null;
360 if (command instanceof PlayPauseType) {
361 if (command.equals(PlayPauseType.PLAY)) {
362 commandPath = "/player/playback/play";
364 if (command.equals(PlayPauseType.PAUSE)) {
365 commandPath = "/player/playback/pause";
369 if (command instanceof NextPreviousType) {
370 if (command.equals(NextPreviousType.PREVIOUS)) {
371 commandPath = "/player/playback/skipPrevious";
373 if (command.equals(NextPreviousType.NEXT)) {
374 commandPath = "/player/playback/skipNext";
378 if (commandPath != null) {
380 String url = "http://" + host + ":" + String.valueOf(port) + commandPath;
381 Properties headers = getClientHeaders();
382 headers.put("X-Plex-Target-Client-Identifier", playerID);
383 HttpUtil.executeUrl("GET", url, headers, null, null, REQUEST_TIMEOUT_MS);
384 } catch (IOException e) {
385 logger.debug("An exception occurred trying to send command '{}' to the player: {}", commandPath,
389 logger.warn("Could not match command '{}' to an action", command);