]> git.basschouten.com Git - openhab-addons.git/blob
6f9db9018a82544d613d1626ac698c041621400d
[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.ScheduledExecutorService;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23
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;
49
50 import com.google.gson.Gson;
51 import com.thoughtworks.xstream.XStream;
52 import com.thoughtworks.xstream.io.xml.StaxDriver;
53
54 /**
55  * The {@link PlexApiConnector} is responsible for communications with the PLEX server
56  *
57  * @author Brian Homeyer - Initial contribution
58  * @author Aron Beurskens - Binding development
59  */
60 @NonNullByDefault
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();
70
71     private final Logger logger = LoggerFactory.getLogger(PlexApiConnector.class);
72     private @Nullable PlexUpdateListener listener;
73
74     private final XStream xStream = new XStream(new StaxDriver());
75     private Gson gson = new Gson();
76     private boolean isShutDown = false;
77
78     private @Nullable ScheduledFuture<?> socketReconnect;
79     private ScheduledExecutorService scheduler;
80     private @Nullable URI uri;
81
82     private String username = "";
83     private String password = "";
84     private String token = "";
85     private String host = "";
86     private int port = 0;
87     private static String scheme = "";
88
89     public PlexApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
90         this.scheduler = scheduler;
91         this.httpClient = httpClient;
92         setupXstream();
93     }
94
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();
103     }
104
105     private String getSchemeWS() {
106         return "http".equals(scheme) ? "ws" : "wss";
107     }
108
109     public boolean hasToken() {
110         return !token.isBlank();
111     }
112
113     /**
114      * Base configuration for XStream
115      */
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);
123     }
124
125     /**
126      * Fetch the XML data and parse it through xStream to get a MediaContainer object
127      *
128      * @return
129      */
130     public @Nullable MediaContainer getSessionData() {
131         try {
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());
138             return null;
139         }
140     }
141
142     /**
143      * Assemble the URL to include the Token
144      *
145      * @param url The url portion that is returned from the sessions call
146      * @return the completed url that will be usable
147      */
148     public String getURL(String url) {
149         String artURL = scheme + "://" + host + ":" + String.valueOf(port + url + "?X-Plex-Token=" + token);
150         return artURL;
151     }
152
153     /**
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
156      */
157     public void getToken() {
158         User user;
159         String authString = Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
160         Properties headers = getClientHeaders();
161         headers.put("Authorization", "Basic " + authString);
162
163         try {
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");
168         }
169
170         if (user.getAuthenticationToken() != null) {
171             token = user.getAuthenticationToken();
172             logger.debug("PLEX login successful using username/password");
173         } else {
174             throw new ConfigurationException("Invalid credentials for PLEX account, please check config");
175         }
176     }
177
178     /**
179      * This method will get the Api information from the PLEX servers.
180      */
181     public boolean getApi() {
182         try {
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();
191                                 logger.debug(
192                                         "PLEX Api fetched. Found configured PLEX server in Api request, applied. Protocol used : {}",
193                                         scheme);
194                                 return true;
195                             }
196                         }
197                     }
198                 }
199             }
200             return false;
201         } catch (IOException e) {
202             logger.debug("An exception occurred while fetching API :'{}'", e.getMessage(), e);
203         }
204         return false;
205     }
206
207     /**
208      * Make an HTTP request and return the class object that was used when calling.
209      *
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
217      */
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);
223         return obj;
224     }
225
226     /**
227      * Fills in the header information for any calls to PLEX services
228      *
229      * @return Property headers
230      */
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");
242         if (hasToken()) {
243             headers.put(TOKEN_HEADER, token);
244         }
245         return headers;
246     }
247
248     /**
249      * Register callback to PlexServerHandler
250      *
251      * @param listener function to call
252      */
253     public void registerListener(PlexUpdateListener listener) {
254         this.listener = listener;
255     }
256
257     /**
258      * Dispose method, cleans up the websocket starts the reconnect logic
259      */
260     public void dispose() {
261         isShutDown = true;
262         try {
263             wsClient.stop();
264             ScheduledFuture<?> socketReconnect = this.socketReconnect;
265             if (socketReconnect != null) {
266                 socketReconnect.cancel(true);
267                 this.socketReconnect = null;
268             }
269             httpClient.stop();
270         } catch (Exception e) {
271             logger.debug("Could not stop webSocketClient,  message {}", e.getMessage());
272         }
273     }
274
275     /**
276      * Connect to the websocket
277      */
278     public void connect() {
279         logger.debug("Connecting to WebSocket");
280         try {
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());
285             return;
286         }
287         wsClient.setConnectTimeout(2000);
288         ClientUpgradeRequest request = new ClientUpgradeRequest();
289         try {
290             isShutDown = false;
291             wsClient.start();
292             wsClient.connect(plexSocket, uri, request);
293         } catch (Exception e) {
294             logger.debug("Could not connect webSocket URI {} message {}", uri, e.getMessage(), e);
295         }
296     }
297
298     /**
299      * PlexSocket class to handle the websocket connection to the PLEX server
300      */
301     @WebSocket(maxIdleTime = 360000) // WEBSOCKET_IDLE_TIMEOUT_MS)
302     public class PlexSocket {
303         @OnWebSocketClose
304         public void onClose(int statusCode, String reason) {
305             logger.debug("Connection closed: {} - {}", statusCode, reason);
306             if (!isShutDown) {
307                 logger.debug("PLEX websocket closed - reconnecting");
308                 asyncWeb();
309             }
310         }
311
312         @OnWebSocketConnect
313         public void onConnect(Session session) {
314             logger.debug("PLEX Socket connected to {}", session.getRemoteAddress().getAddress());
315         }
316
317         @OnWebSocketMessage
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)
325                                     .getSessionKey(),
326                             notification.getNotificationContainer().getPlaySessionStateNotification().get(0)
327                                     .getState());
328                 }
329             }
330         }
331
332         @OnWebSocketError
333         public void onError(Throwable cause) {
334             if (!isShutDown) {
335                 logger.debug("WebSocket onError - reconnecting");
336                 asyncWeb();
337             }
338         }
339
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,
344             }
345         }
346     }
347
348     /**
349      * Handles control commands to the plex player.
350      *
351      * Supports:
352      * - Play / Pause
353      * - Previous / Next
354      *
355      * @param command The control command
356      * @param playerID The ID of the PLEX player
357      */
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";
363             }
364             if (command.equals(PlayPauseType.PAUSE)) {
365                 commandPath = "/player/playback/pause";
366             }
367         }
368
369         if (command instanceof NextPreviousType) {
370             if (command.equals(NextPreviousType.PREVIOUS)) {
371                 commandPath = "/player/playback/skipPrevious";
372             }
373             if (command.equals(NextPreviousType.NEXT)) {
374                 commandPath = "/player/playback/skipNext";
375             }
376         }
377
378         if (commandPath != null) {
379             try {
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,
386                         e.getMessage());
387             }
388         } else {
389             logger.warn("Could not match command '{}' to an action", command);
390         }
391     }
392 }