/**
* Copyright (c) 2010-2016 by the respective copyright holders.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.openhab.binding.plex.internal;
import static org.apache.commons.lang.StringUtils.*;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ExecutionException;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.SystemUtils;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.DeserializationConfig.Feature;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.jboss.netty.handler.timeout.TimeoutException;
import org.openhab.binding.plex.internal.communication.AbstractSessionItem;
import org.openhab.binding.plex.internal.communication.Connection;
import org.openhab.binding.plex.internal.communication.Device;
import org.openhab.binding.plex.internal.communication.MediaContainer;
import org.openhab.binding.plex.internal.communication.Player;
import org.openhab.binding.plex.internal.communication.Server;
import org.openhab.binding.plex.internal.communication.SessionUpdate;
import org.openhab.binding.plex.internal.communication.Track;
import org.openhab.binding.plex.internal.communication.User;
import org.openhab.binding.plex.internal.communication.websocket.NotificationContainer;
import org.openhab.binding.plex.internal.communication.websocket.Update;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ning.http.client.AsyncCompletionHandler;
import com.ning.http.client.AsyncHttpClient;
import com.ning.http.client.AsyncHttpClient.BoundRequestBuilder;
import com.ning.http.client.AsyncHttpClientConfig;
import com.ning.http.client.AsyncHttpClientConfig.Builder;
import com.ning.http.client.Response;
import com.ning.http.client.providers.netty.NettyAsyncHttpProvider;
import com.ning.http.client.websocket.WebSocket;
import com.ning.http.client.websocket.WebSocketTextListener;
import com.ning.http.client.websocket.WebSocketUpgradeHandler;
import com.ning.http.util.Base64;
/**
* Manages the web socket connection to a Plex server. Also responsible for sending HTTP GET commands.
*
* @author Jeroen Idserda
* @since 1.7.0
*/
public class PlexConnector extends Thread {
private static final Logger logger = LoggerFactory.getLogger(PlexConnector.class);
private static final int REQUEST_TIMEOUT_MS = 10000;
private static final int RECONNECT_DELAY = 5000;
private static final int VOLUME_STEP = 5;
/**
* Generated random client ID for openHAB
*/
private static final String CLIENT_ID = "3e4e9b32-d366-47e2-a378-03044e9d1338";
private static final String SIGN_IN_URL = "https://plex.tv/users/sign_in.xml";
private static final String API_RESOURCES_URL = "https://plex.tv/api/resources?includeHttps=1";
private static final String TOKEN_HEADER = "X-Plex-Token";
private final AsyncHttpClient client;
private final WebSocketUpgradeHandler handler;
private final PlexConnectionProperties connection;
private final PlexUpdateReceivedCallback callback;
private boolean running = true;
/**
* Websocket URI
*/
private final String wsUri;
/**
* URL for accessing session information
*/
private final String sessionsUrl;
/**
* URL for fetching the connected clients
*/
private final String clientsUrl;
private boolean connected;
private WebSocket webSocket;
private Map<String, PlexSession> sessions = new HashMap<String, PlexSession>();
/**
* Client cache
*/
private static Integer CACHE_VALID_TIME = 900000; // 15 minutes
private MediaContainer clientCache;
private Date lastClientCacheUpdate;
/**
* Create a connector for a single connection to a Plex server
*
* @param connection
* Connection properties
* @param callback
* Called when a state update is received
* @throws UnknownHostException
* If hostname is not resolvable.
*/
public PlexConnector(PlexConnectionProperties connection, PlexUpdateReceivedCallback callback)
throws UnknownHostException {
this.connection = connection;
this.callback = callback;
requestToken();
resolveServer();
this.wsUri = String.format("%s://%s:%d/:/websockets/notifications",
connection.getUri().getScheme().equals("https") ? "wss" : "ws", connection.getUri().getHost(),
connection.getUri().getPort());
this.sessionsUrl = String.format("%s/status/sessions", connection.getUri().toString());
this.clientsUrl = String.format("%s/clients", connection.getUri().toString());
this.client = new AsyncHttpClient(new NettyAsyncHttpProvider(createAsyncHttpClientConfig()));
this.handler = createWebSocketHandler();
}
/**
* Check if the connection to the Plex server is active
*
* @return true if an active connection to the Plex server exists, false otherwise
*/
public boolean isConnected() {
if (webSocket == null || !webSocket.isOpen()) {
return false;
}
return connected;
}
/**
* Run initial connection attempt to Plex in this separate thread
*/
@Override
public void run() {
connect();
}
/**
* Attempts to create a web socket connection to the Plex server and begins listening for updates
*
* @throws IOException
* @throws InterruptedException
* @throws ExecutionException
*/
private void open() throws IOException, InterruptedException, ExecutionException {
close();
webSocket = client.prepareGet(addDefaultQueryParameters(wsUri)).execute(handler).get();
}
/**
* Closes the web socket connection
*/
public void close() {
if (webSocket != null) {
running = false;
webSocket.close();
webSocket = null;
}
}
/**
* Send command to Plex
*
* @param config
* The binding configuration for the item
* @param command
* Command to send
*
* @throws IOException
* When it's not possible to send HTTP GET command
*/
public void sendCommand(PlexBindingConfig config, Command command) throws IOException {
String cmd = null;
String property = config.getProperty();
if (property.equals(PlexProperty.VOLUME.getName())) {
cmd = getVolumeCommand(config, command);
} else if (property.equals(PlexProperty.PROGRESS.getName())) {
cmd = getProgressCommand(config, command);
} else if (property.equals(PlexProperty.PLAYPAUSE.getName())) {
cmd = getPlayPauseCommand(config);
} else {
cmd = config.getProperty();
}
if (cmd != null) {
Server host = getHost(config.getMachineIdentifier());
if (host != null && !isBlank(host.getHost())) {
String uri = String.format("http://%s:%s/player/%s", host.getHost(), host.getPort(), cmd);
uri = appendParametersForCommand(uri, config.getMachineIdentifier());
internalSendCommand(config.getMachineIdentifier(), uri);
} else {
logger.debug("Cannot send command, host is unknown for machine ID {}", config.getMachineIdentifier());
}
}
}
/**
* Finds a PlexSession for a certain client identified by machineIdentifier
*
* @param machineIdentifier
* Plex client ID
* @return Session for the machineIdentifier or null
*/
public PlexSession getSessionByMachineId(String machineIdentifier) {
for (Entry<String, PlexSession> session : sessions.entrySet()) {
if (session.getValue().getMachineIdentifier().equals(machineIdentifier)) {
return session.getValue();
}
}
return null;
}
private String getVolumeCommand(PlexBindingConfig config, Command command) {
int newVolume = 100;
PlexSession session = getSessionByMachineId(config.getMachineIdentifier());
if (session != null) {
newVolume = session.getVolume();
}
if (command.getClass().equals(PercentType.class)) {
PercentType percentType = (PercentType) command;
newVolume = percentType.intValue();
} else if (command.getClass().equals(IncreaseDecreaseType.class)) {
if (command.equals(IncreaseDecreaseType.DECREASE)) {
newVolume = Math.max(0, newVolume - VOLUME_STEP);
} else {
newVolume = Math.min(100, newVolume + VOLUME_STEP);
}
}
if (session != null) {
session.setVolume(newVolume);
callback.updateReceived(session);
}
String url = String.format("playback/setParameters?volume=%d", newVolume);
return url;
}
private String getProgressCommand(PlexBindingConfig config, Command command) {
PlexSession session = getSessionByMachineId(config.getMachineIdentifier());
String url = null;
if (session != null) {
int offset = 0;
if (command.getClass().equals(PercentType.class)) {
PercentType percent = (PercentType) command;
offset = new BigDecimal(session.getDuration()).multiply(
percent.toBigDecimal().divide(new BigDecimal("100"), new MathContext(5, RoundingMode.HALF_UP)))
.intValue();
offset = Math.max(0, offset);
offset = Math.min(session.getDuration(), offset);
url = String.format("playback/seekTo?offset=%d", offset);
} else if (command.getClass().equals(IncreaseDecreaseType.class)) {
if (command.equals(IncreaseDecreaseType.DECREASE)) {
url = PlexProperty.STEP_BACK.getName();
} else {
url = PlexProperty.STEP_FORWARD.getName();
}
}
}
return url;
}
private String getPlayPauseCommand(PlexBindingConfig config) {
String command = PlexProperty.PAUSE.getName();
PlexSession session = getSessionByMachineId(config.getMachineIdentifier());
if (session != null) {
if (PlexPlayerState.Paused.equals(session.getState())) {
command = PlexProperty.PLAY.getName();
}
}
return command;
}
private WebSocketUpgradeHandler createWebSocketHandler() {
WebSocketUpgradeHandler.Builder builder = new WebSocketUpgradeHandler.Builder();
builder.addWebSocketListener(new PlexWebSocketListener());
return builder.build();
}
private AsyncHttpClientConfig createAsyncHttpClientConfig() {
Builder builder = new AsyncHttpClientConfig.Builder();
builder.setRequestTimeoutInMs(REQUEST_TIMEOUT_MS);
return builder.build();
}
private synchronized void refreshSessions() {
logger.debug("Refreshing Plex sessions");
MediaContainer container = getDocument(sessionsUrl, MediaContainer.class);
if (container != null) {
Map<String, PlexSession> previousSessions = new HashMap<String, PlexSession>(sessions);
sessions.clear();
addSessionFor(container.getVideos());
addSessionFor(container.getTracks());
setVolumeFromPreviousSessions(previousSessions);
}
}
private void addSessionFor(List<? extends AbstractSessionItem> items) {
for (AbstractSessionItem item : items) {
PlexSession session = new PlexSession();
fillSession(session, item);
sessions.put(session.getSessionKey(), session);
}
}
private void fillSession(PlexSession session, AbstractSessionItem item) {
Player player = item.getPlayer();
session.setSessionKey(item.getSessionKey());
session.setState(PlexPlayerState.of(player.getState()));
if (!isBlank(item.getGrandparentTitle())) {
session.setTitle(item.getGrandparentTitle() + " - " + item.getTitle());
} else {
session.setTitle(item.getTitle());
}
session.setType(item.getType());
session.setMachineIdentifier(player.getMachineIdentifier());
if (isNumeric(item.getDuration())) {
session.setDuration(Integer.valueOf(item.getDuration()));
}
session.setCover(getCover(item));
session.setKey(item.getKey());
}
private String getCover(AbstractSessionItem item) {
String cover = null;
// Only use grandparentThumb if it's present in the session item
// and if the session item is not a music track
if (!isBlank(item.getGrandparentThumb()) && !item.getClass().equals(Track.class)) {
cover = item.getGrandparentThumb();
} else if (!isBlank(item.getThumb())) {
cover = item.getThumb();
}
if (!isBlank(cover)) {
cover = addDefaultQueryParameters(String.format("%s%s", connection.getUri().toString(), cover));
}
return cover;
}
private Server getHost(String machineIdentifier) {
if (clientCache == null || new Date().getTime() - lastClientCacheUpdate.getTime() > CACHE_VALID_TIME
|| clientCache.getServer(machineIdentifier) == null) {
lastClientCacheUpdate = new Date();
clientCache = getDocument(clientsUrl, MediaContainer.class);
}
Server server = clientCache.getServer(machineIdentifier);
if (server != null) {
return server;
}
return null;
}
private void internalSendCommand(String machineIdentifier, String url) throws IOException {
logger.debug("Calling url {}", url);
BoundRequestBuilder builder = client.prepareGet(url).setHeaders(getDefaultHeaders())
.setHeader("X-Plex-Target-Client-Identifier", machineIdentifier);
builder.execute(new AsyncCompletionHandler<Response>() {
@Override
public Response onCompleted(Response response) throws Exception {
if (response.getStatusCode() != 200) {
logger.error("Error while sending command to Plex: {}\r\n{}", response.getStatusText(),
response.getResponseBody());
}
return response;
}
@Override
public void onThrowable(Throwable t) {
logger.error("Error sending command to Plex", t);
}
});
}
private PlexSession getSession(String sessionKey, String key) {
if (!sessions.containsKey(sessionKey) || !sessions.get(sessionKey).getKey().equals(key)) {
refreshSessions();
}
if (sessions.containsKey(sessionKey)) {
return sessions.get(sessionKey);
}
return null;
}
private void setVolumeFromPreviousSessions(Map<String, PlexSession> previousSessions) {
for (Entry<String, PlexSession> sessionEntry : sessions.entrySet()) {
PlexSession newSession = sessionEntry.getValue();
String machineIdentifier = newSession.getMachineIdentifier();
for (Entry<String, PlexSession> session : previousSessions.entrySet()) {
if (session.getValue().getMachineIdentifier().equals(machineIdentifier)) {
newSession.setVolume(session.getValue().getVolume());
return;
}
}
}
}
private void connect() {
int delay = 0;
logger.debug("Connecting web socket to Plex");
while (!isConnected()) {
try {
Thread.sleep(delay);
open();
} catch (IOException e) {
logger.debug("Error connecting to Plex", e);
} catch (InterruptedException e) {
logger.debug("Interrupted while connecting to Plex", e);
} catch (ExecutionException e) {
logger.debug("Error connecting to Plex", e);
}
delay = RECONNECT_DELAY;
}
}
/**
* Listener for web socket. Receives and parses status updates from Plex.
*
* @author Jeroen Idserda
* @since 1.7.0
*/
private class PlexWebSocketListener implements WebSocketTextListener {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void onOpen(WebSocket webSocket) {
logger.info("Plex websocket connected to {}:{}", connection.getUri().getHost(),
connection.getUri().getPort());
connected = true;
}
@Override
public void onError(Throwable e) {
if (e instanceof ConnectException) {
logger.debug("[{}]: Websocket connection error", connection.getUri().getHost());
} else if (e instanceof TimeoutException) {
logger.debug("[{}]: Websocket timeout error", connection.getUri().getHost());
} else {
logger.debug("[{}]: Websocket error: {}", connection.getUri().getHost(), e.getMessage());
}
}
@Override
public void onClose(WebSocket webSocket) {
logger.warn("[{}]: Websocket closed", connection.getUri().getHost());
webSocket = null;
connected = false;
if (running) {
connect();
}
}
@Override
public void onMessage(String message) {
logger.debug("[{}]: Message received: {}", connection.getUri().getHost(), message);
SessionUpdate update = getSessionUpdateFrom(message);
if (update != null && isNotBlank(update.getSessionKey())) {
String sessionKey = update.getSessionKey();
String key = update.getKey();
String state = update.getState();
PlexSession session = getSession(sessionKey, key);
if (!isBlank(state) && session != null) {
PlexPlayerState playerState = PlexPlayerState.of(state);
session.setState(playerState);
session.setViewOffset(update.getViewOffset());
callback.updateReceived(session);
}
}
}
private SessionUpdate getSessionUpdateFrom(String message) {
try {
switch (connection.getApiLevel()) {
case v1:
mapper.configure(Feature.UNWRAP_ROOT_VALUE, false);
Update update = mapper.readValue(message, Update.class);
if (update.getType().equals("playing") && update.getChildren().size() > 0) {
return update.getChildren().get(0);
}
break;
case v2:
mapper.configure(Feature.UNWRAP_ROOT_VALUE, true);
NotificationContainer notificationContainer = mapper.readValue(message,
NotificationContainer.class);
if (notificationContainer.getStateNotifications().size() > 0) {
return notificationContainer.getStateNotifications().get(0);
}
break;
}
} catch (JsonParseException e) {
logger.error("Error parsing JSON", e);
} catch (JsonMappingException e) {
logger.error("Error mapping JSON", e);
} catch (IOException e) {
logger.error("An I/O error occured while decoding JSON", e);
}
return null;
}
@Override
public void onFragment(String fragment, boolean last) {
}
}
public void refresh() {
MediaContainer container = getDocument(clientsUrl, MediaContainer.class);
if (container != null) {
callback.serverListUpdated(container);
}
}
private <T> T getDocument(String uri, Class<T> type) {
return doHttpRequest("GET", uri, new HashMap<String, String>(), type);
}
private <T> T postDocument(String uri, Map<String, String> parameters, Class<T> type) {
return doHttpRequest("POST", uri, parameters, type);
}
private <T> T doHttpRequest(String method, String uri, Map<String, String> parameters, Class<T> type) {
try {
HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(REQUEST_TIMEOUT_MS);
connection.setReadTimeout(REQUEST_TIMEOUT_MS);
for (Entry<String, String> entry : parameters.entrySet()) {
connection.addRequestProperty(entry.getKey(), entry.getValue());
}
for (Entry<String, Collection<String>> entry : getDefaultHeaders().entrySet()) {
connection.addRequestProperty(entry.getKey(), StringUtils.join(entry.getValue(), ", "));
}
JAXBContext jc = JAXBContext.newInstance(type);
InputStream xml = connection.getInputStream();
@SuppressWarnings("unchecked")
T obj = (T) jc.createUnmarshaller().unmarshal(xml);
connection.disconnect();
return obj;
} catch (MalformedURLException e) {
logger.debug(e.getMessage(), e);
} catch (IOException e) {
logger.debug(e.getMessage(), e);
} catch (JAXBException e) {
logger.debug(e.getMessage(), e);
}
return null;
}
private void resolveServer() throws UnknownHostException {
MediaContainer container = getDocument(API_RESOURCES_URL, MediaContainer.class);
// We need the IP-address to find this server in the server list on plex.tv
String ip = resolveHostname(connection.getHost());
if (container != null) {
for (Device device : container.getDevices()) {
if (contains(device.getProvides(), "server")) {
for (Connection deviceConnection : device.getConnections()) {
boolean uriSet = (connection.getUri() != null);
boolean portEqual = String.valueOf(connection.getPort()).equals(deviceConnection.getPort());
boolean hostEqual = ip.equals(deviceConnection.getAddress());
if (!uriSet && portEqual && hostEqual) {
connection.setUri(deviceConnection.getUri());
connection.setApiLevel(PlexApiLevel.getApiLevel(device.getProductVersion()));
logger.debug("Server found, version {}, api level {}", device.getProductVersion(),
connection.getApiLevel());
}
}
}
}
}
if (connection.getUri() == null) {
logger.warn(
"Server not found in plex.tv device list, setting URI from configured data. Try configuring IP-address of host.");
connection.setUri(String.format("http://%s:%d", ip, connection.getPort()));
}
}
private void requestToken() {
boolean tokenPresent = !isEmpty(connection.getToken());
boolean usernamePresent = !isEmpty(connection.getUsername());
boolean passwordPresent = !isEmpty(connection.getPassword());
if (!tokenPresent && usernamePresent && passwordPresent) {
Map<String, String> parameters = new HashMap<String, String>();
String authString = Base64.encode((connection.getUsername() + ":" + connection.getPassword()).getBytes());
parameters.put("Authorization", "Basic " + authString);
User user = postDocument(SIGN_IN_URL, parameters, User.class);
if (user != null) {
logger.debug("Plex login successful");
connection.setToken(user.getAuthenticationToken());
} else {
logger.warn("Invalid credentials for Plex account");
}
}
}
private String appendParametersForCommand(String uri, String machineIdentifier) {
List<String> parameters = new ArrayList<String>();
if (!isEmpty(machineIdentifier)) {
PlexSession session = getSessionByMachineId(machineIdentifier);
if (session != null) {
String type = "video";
if ("track".equals(session.getType())) {
type = "music";
}
parameters.add(String.format("%s=%s", "type", type));
}
}
if (!parameters.isEmpty()) {
uri += (!uri.contains("?") ? "?" : "&") + StringUtils.join(parameters, "&");
}
return uri;
}
private Map<String, Collection<String>> getDefaultHeaders() {
Map<String, Collection<String>> headers = new HashMap<String, Collection<String>>();
headers.put("X-Plex-Client-Identifier", Arrays.asList(CLIENT_ID));
headers.put("X-Plex-Product", Arrays.asList("openHAB"));
headers.put("X-Plex-Version", Arrays.asList(PlexActivator.getVersion().toString()));
headers.put("X-Plex-Device", Arrays.asList(SystemUtils.JAVA_RUNTIME_NAME));
headers.put("X-Plex-Device-Name", Arrays.asList("openHAB"));
headers.put("X-Plex-Provides", Arrays.asList("controller"));
headers.put("X-Plex-Platform", Arrays.asList("Java"));
headers.put("X-Plex-Platform-Version", Arrays.asList(SystemUtils.JAVA_VERSION));
if (connection.hasToken()) {
headers.put(TOKEN_HEADER, Arrays.asList(connection.getToken()));
}
return headers;
}
private String addDefaultQueryParameters(String uri) {
if (connection.hasToken()) {
uri += "?" + TOKEN_HEADER + "=" + connection.getToken();
}
return uri;
}
private String resolveHostname(String host) throws UnknownHostException {
InetAddress address = InetAddress.getByName(host);
return address.getHostAddress();
}
}