[jellyfin] initial contribution (#11939)
* [jellyfin] initial contribution Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * [jellyfin] update parent version Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * update license header year Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * add example to readme Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * apply pr review Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * apply pr review Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * apply pr review Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * add third-party info Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com> * upgrade sdk to release 1.2.0 Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.jellyfin-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-jellyfin" description="Jellyfin Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.jellyfin/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinBindingConstants {
|
||||
|
||||
static final String BINDING_ID = "jellyfin";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_SERVER = new ThingTypeUID(BINDING_ID, "server");
|
||||
public static final ThingTypeUID THING_TYPE_CLIENT = new ThingTypeUID(BINDING_ID, "client");
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_SERVER, THING_TYPE_CLIENT);
|
||||
|
||||
// List of all Channel ids
|
||||
public static final String SEND_NOTIFICATION_CHANNEL = "send-notification";
|
||||
public static final String MEDIA_CONTROL_CHANNEL = "media-control";
|
||||
public static final String PLAYING_ITEM_PERCENTAGE_CHANNEL = "playing-item-percentage";
|
||||
public static final String PLAYING_ITEM_NAME_CHANNEL = "playing-item-name";
|
||||
public static final String PLAYING_ITEM_SERIES_NAME_CHANNEL = "playing-item-series-name";
|
||||
public static final String PLAYING_ITEM_SEASON_NAME_CHANNEL = "playing-item-season-name";
|
||||
public static final String PLAYING_ITEM_SEASON_CHANNEL = "playing-item-season";
|
||||
public static final String PLAYING_ITEM_EPISODE_CHANNEL = "playing-item-episode";
|
||||
public static final String PLAYING_ITEM_GENRES_CHANNEL = "playing-item-genders";
|
||||
public static final String PLAYING_ITEM_TYPE_CHANNEL = "playing-item-type";
|
||||
public static final String PLAYING_ITEM_SECOND_CHANNEL = "playing-item-second";
|
||||
public static final String PLAYING_ITEM_TOTAL_SECOND_CHANNEL = "playing-item-total-seconds";
|
||||
public static final String PLAY_BY_TERMS_CHANNEL = "play-by-terms";
|
||||
public static final String PLAY_NEXT_BY_TERMS_CHANNEL = "play-next-by-terms";
|
||||
public static final String PLAY_LAST_BY_TERMS_CHANNEL = "play-last-by-terms";
|
||||
public static final String BROWSE_ITEM_BY_TERMS_CHANNEL = "browse-by-terms";
|
||||
|
||||
// Discovery
|
||||
public static final int DISCOVERY_RESULT_TTL_SEC = 600;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal;
|
||||
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.BINDING_ID;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.SUPPORTED_THING_TYPES;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.THING_TYPE_CLIENT;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.THING_TYPE_SERVER;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.jellyfin.internal.handler.JellyfinClientHandler;
|
||||
import org.openhab.binding.jellyfin.internal.handler.JellyfinServerHandler;
|
||||
import org.openhab.binding.jellyfin.internal.servlet.JellyfinBridgeServlet;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
import org.osgi.service.http.HttpService;
|
||||
import org.osgi.service.http.NamespaceException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.jellyfin", service = ThingHandlerFactory.class)
|
||||
public class JellyfinHandlerFactory extends BaseThingHandlerFactory {
|
||||
private final HttpService httpService;
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinHandlerFactory.class);
|
||||
private final Map<ThingUID, JellyfinBridgeServlet> servletRegistrations = new HashMap<>();
|
||||
|
||||
@Activate
|
||||
public JellyfinHandlerFactory(@Reference HttpService httpService) {
|
||||
this.httpService = httpService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
if (THING_TYPE_SERVER.equals(thingTypeUID)) {
|
||||
var serverHandler = new JellyfinServerHandler((Bridge) thing);
|
||||
registerAuthenticationServlet(serverHandler);
|
||||
return serverHandler;
|
||||
}
|
||||
if (THING_TYPE_CLIENT.equals(thingTypeUID)) {
|
||||
return new JellyfinClientHandler(thing);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void removeHandler(ThingHandler thingHandler) {
|
||||
if (thingHandler instanceof JellyfinServerHandler) {
|
||||
var serverHandler = (JellyfinServerHandler) thingHandler;
|
||||
unregisterAuthenticationServlet(serverHandler);
|
||||
}
|
||||
super.removeHandler(thingHandler);
|
||||
}
|
||||
|
||||
private synchronized void registerAuthenticationServlet(JellyfinServerHandler bridgeHandler) {
|
||||
var auth = new JellyfinBridgeServlet(bridgeHandler);
|
||||
try {
|
||||
httpService.registerServlet(getAuthenticationServletPath(bridgeHandler), auth, null,
|
||||
httpService.createDefaultHttpContext());
|
||||
} catch (NamespaceException | ServletException e) {
|
||||
logger.warn("Register servlet fails", e);
|
||||
}
|
||||
servletRegistrations.put(bridgeHandler.getThing().getUID(), auth);
|
||||
}
|
||||
|
||||
private synchronized void unregisterAuthenticationServlet(JellyfinServerHandler bridgeHandler) {
|
||||
var loginServlet = servletRegistrations.get(bridgeHandler.getThing().getUID());
|
||||
if (loginServlet != null) {
|
||||
httpService.unregister(getAuthenticationServletPath(bridgeHandler));
|
||||
}
|
||||
}
|
||||
|
||||
private String getAuthenticationServletPath(JellyfinServerHandler bridgeHandler) {
|
||||
return new StringBuilder().append("/").append(BINDING_ID).append("/")
|
||||
.append(bridgeHandler.getThing().getUID().getId()).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinServerConfiguration} class contains fields mapping thing configuration parameters.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinServerConfiguration {
|
||||
/**
|
||||
* Server hostname
|
||||
*/
|
||||
public String hostname = "";
|
||||
/**
|
||||
* Server hostname
|
||||
*/
|
||||
public int port = 8096;
|
||||
/**
|
||||
* Use Https
|
||||
*/
|
||||
public Boolean ssl = true;
|
||||
/**
|
||||
* Interval to pull devices state from the server
|
||||
*/
|
||||
public int refreshSeconds = 60;
|
||||
/**
|
||||
* Amount off seconds allowed since the last client update to assert it's online
|
||||
*/
|
||||
public int clientActiveWithInSeconds = 0;
|
||||
/**
|
||||
* Access Token
|
||||
*/
|
||||
public String token = "";
|
||||
/**
|
||||
* User ID
|
||||
*/
|
||||
public String userId = "";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.DISCOVERY_RESULT_TTL_SEC;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.THING_TYPE_CLIENT;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.THING_TYPE_SERVER;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException;
|
||||
import org.jellyfin.sdk.model.api.SessionInfo;
|
||||
import org.openhab.binding.jellyfin.internal.handler.JellyfinServerHandler;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncCallback;
|
||||
import org.openhab.core.config.discovery.AbstractDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinClientDiscoveryService} discover Jellyfin clients connected to the server.
|
||||
*
|
||||
* @author Miguel Alvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinClientDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinClientDiscoveryService.class);
|
||||
private @Nullable JellyfinServerHandler bridgeHandler;
|
||||
|
||||
public JellyfinClientDiscoveryService() throws IllegalArgumentException {
|
||||
super(Set.of(THING_TYPE_SERVER), 60);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
var bridgeHandler = this.bridgeHandler;
|
||||
if (bridgeHandler == null) {
|
||||
logger.warn("missing bridge aborting");
|
||||
return;
|
||||
}
|
||||
if (!bridgeHandler.getThing().getStatus().equals(ThingStatus.ONLINE)) {
|
||||
logger.warn("Server handler {} is not online.", bridgeHandler.getThing().getLabel());
|
||||
return;
|
||||
}
|
||||
logger.debug("Searching devices for server {}", bridgeHandler.getThing().getLabel());
|
||||
try {
|
||||
bridgeHandler.getControllableSessions().forEach(this::discoverDevice);
|
||||
} catch (SyncCallback.SyncCallbackError syncCallbackError) {
|
||||
logger.error("Unexpected error: {}", syncCallbackError.getMessage());
|
||||
} catch (InvalidStatusException e) {
|
||||
logger.warn("Api client error with status{}: {}", e.getStatus(), e.getMessage());
|
||||
} catch (ApiClientException e) {
|
||||
logger.warn("Api client error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void discoverDevice(SessionInfo info) {
|
||||
var id = info.getDeviceId();
|
||||
if (id == null) {
|
||||
logger.warn("missing device id aborting");
|
||||
return;
|
||||
}
|
||||
var bridgeHandler = this.bridgeHandler;
|
||||
if (bridgeHandler == null) {
|
||||
logger.warn("missing bridge aborting");
|
||||
return;
|
||||
}
|
||||
logger.debug("Client discovered: [{}] {}", id, info.getDeviceName());
|
||||
var bridgeUID = bridgeHandler.getThing().getUID();
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.put(Thing.PROPERTY_SERIAL_NUMBER, id);
|
||||
var appVersion = info.getApplicationVersion();
|
||||
if (appVersion != null) {
|
||||
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, appVersion);
|
||||
}
|
||||
var client = info.getApplicationVersion();
|
||||
if (client != null) {
|
||||
properties.put(Thing.PROPERTY_VENDOR, client);
|
||||
}
|
||||
thingDiscovered(
|
||||
DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_CLIENT, bridgeUID, id)).withBridge(bridgeUID)
|
||||
.withTTL(DISCOVERY_RESULT_TTL_SEC).withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER)
|
||||
.withProperties(properties).withLabel(info.getDeviceName()).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setThingHandler(ThingHandler thingHandler) {
|
||||
if (thingHandler instanceof JellyfinServerHandler) {
|
||||
bridgeHandler = (JellyfinServerHandler) thingHandler;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void activate() {
|
||||
activate(new HashMap<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deactivate() {
|
||||
super.deactivate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jellyfin.sdk.Jellyfin;
|
||||
import org.jellyfin.sdk.JellyfinOptions;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
import org.jellyfin.sdk.api.operations.SystemApi;
|
||||
import org.jellyfin.sdk.compatibility.JavaFlow;
|
||||
import org.jellyfin.sdk.model.ClientInfo;
|
||||
import org.jellyfin.sdk.model.DeviceInfo;
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo;
|
||||
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncCallback;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncResponse;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.openhab.core.config.discovery.AbstractDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.config.discovery.DiscoveryService;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinServerDiscoveryService} discover Jellyfin servers in the network.
|
||||
*
|
||||
* @author Miguel Alvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = DiscoveryService.class, configurationPid = "discovery.jellyfin")
|
||||
public class JellyfinServerDiscoveryService extends AbstractDiscoveryService {
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinServerDiscoveryService.class);
|
||||
private JavaFlow.@Nullable FlowJob cancelDiscovery;
|
||||
|
||||
public JellyfinServerDiscoveryService() throws IllegalArgumentException {
|
||||
super(Set.of(THING_TYPE_CLIENT), 60);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
var opts = new JellyfinOptions.Builder();
|
||||
opts.setClientInfo(new ClientInfo("openHAB", OpenHAB.getVersion()));
|
||||
opts.setDeviceInfo(new DeviceInfo("discovery", "openHAB"));
|
||||
var jellyfin = new Jellyfin(opts.build());
|
||||
var discoverySvc = new org.jellyfin.sdk.discovery.DiscoveryService(jellyfin);
|
||||
logger.debug("Starting search");
|
||||
cancelDiscovery = JavaFlow.collect(discoverySvc.discoverLocalServers(100, 10), null, (info) -> {
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
logger.debug("Server found: [{}] {}", info.getId(), info.getName());
|
||||
processDiscoveryResult(jellyfin, info);
|
||||
}, (throwable) -> {
|
||||
if (throwable != null) {
|
||||
logger.warn("Discovery Error: {}", throwable.getMessage());
|
||||
} else {
|
||||
logger.debug("Discovery ends");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void stopScan() {
|
||||
super.stopScan();
|
||||
var cancelDiscovery = this.cancelDiscovery;
|
||||
if (cancelDiscovery != null) {
|
||||
cancelDiscovery.close();
|
||||
this.cancelDiscovery = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void processDiscoveryResult(Jellyfin jellyfin, ServerDiscoveryInfo info) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(Objects.requireNonNull(info.getAddress()));
|
||||
} catch (URISyntaxException e) {
|
||||
logger.warn("Error parsing server url: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
var jellyClient = jellyfin.createApi(info.getAddress());
|
||||
var asyncResponse = new SyncResponse<PublicSystemInfo>();
|
||||
new SystemApi(jellyClient).getPublicSystemInfo(asyncResponse);
|
||||
try {
|
||||
var publicSystemInfo = asyncResponse.awaitContent();
|
||||
discoverServer(uri.getHost(), uri.getPort(), uri.getScheme().equalsIgnoreCase("https"), publicSystemInfo);
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
logger.warn("Discovery error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void discoverServer(String hostname, int port, boolean ssl, PublicSystemInfo publicSystemInfo) {
|
||||
logger.debug("Server discovered: [{}:{}] {}", hostname, port, publicSystemInfo.getServerName());
|
||||
var id = Objects.requireNonNull(publicSystemInfo.getId());
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.put("hostname", hostname);
|
||||
properties.put("port", port);
|
||||
properties.put("ssl", ssl);
|
||||
properties.put(Thing.PROPERTY_SERIAL_NUMBER, id);
|
||||
var productName = publicSystemInfo.getProductName();
|
||||
if (productName != null) {
|
||||
properties.put(Thing.PROPERTY_VENDOR, productName);
|
||||
}
|
||||
var version = publicSystemInfo.getVersion();
|
||||
if (version != null) {
|
||||
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
|
||||
}
|
||||
thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_SERVER, publicSystemInfo.getId()))
|
||||
.withTTL(DISCOVERY_RESULT_TTL_SEC).withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER)
|
||||
.withProperties(properties).withLabel(publicSystemInfo.getServerName()).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.handler;
|
||||
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.BROWSE_ITEM_BY_TERMS_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.MEDIA_CONTROL_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_EPISODE_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_GENRES_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_NAME_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_PERCENTAGE_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SEASON_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SEASON_NAME_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SECOND_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SERIES_NAME_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_TOTAL_SECOND_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_TYPE_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_BY_TERMS_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_LAST_BY_TERMS_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_NEXT_BY_TERMS_CHANNEL;
|
||||
import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.SEND_NOTIFICATION_CHANNEL;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.PlayCommand;
|
||||
import org.jellyfin.sdk.model.api.PlayerStateInfo;
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand;
|
||||
import org.jellyfin.sdk.model.api.SessionInfo;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncCallback;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.NextPreviousType;
|
||||
import org.openhab.core.library.types.PercentType;
|
||||
import org.openhab.core.library.types.PlayPauseType;
|
||||
import org.openhab.core.library.types.RewindFastforwardType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinClientHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinClientHandler extends BaseThingHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinClientHandler.class);
|
||||
private final Pattern typeSearchPattern = Pattern.compile("<type:(?<type>movie|series|episode)>\\s?(?<terms>.*)");
|
||||
private final Pattern seriesSearchPattern = Pattern
|
||||
.compile("(<type:series>)?<season:(?<season>[0-9]*)><episode:(?<episode>[0-9]*)>\\s?(?<terms>.*)");
|
||||
private @Nullable ScheduledFuture<?> delayedCommand;
|
||||
private String lastSessionId = "";
|
||||
private boolean lastPlayingState = false;
|
||||
private long lastRunTimeTicks = 0L;
|
||||
|
||||
public JellyfinClientHandler(Thing thing) {
|
||||
super(thing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
scheduler.execute(() -> refreshState());
|
||||
}
|
||||
|
||||
public synchronized void updateStateFromSession(@Nullable SessionInfo session) {
|
||||
if (session != null) {
|
||||
lastSessionId = Objects.requireNonNull(session.getId());
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
updateChannelStates(session.getNowPlayingItem(), session.getPlayState());
|
||||
} else {
|
||||
lastPlayingState = false;
|
||||
cleanChannels();
|
||||
updateStatus(ThingStatus.OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
try {
|
||||
switch (channelUID.getId()) {
|
||||
case SEND_NOTIFICATION_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
return;
|
||||
}
|
||||
sendDeviceMessage(command);
|
||||
break;
|
||||
case MEDIA_CONTROL_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
refreshState();
|
||||
return;
|
||||
}
|
||||
handleMediaControlCommand(channelUID, command);
|
||||
break;
|
||||
case PLAY_BY_TERMS_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
return;
|
||||
}
|
||||
runItemSearch(command.toFullString(), PlayCommand.PLAY_NOW);
|
||||
break;
|
||||
case PLAY_NEXT_BY_TERMS_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
return;
|
||||
}
|
||||
runItemSearch(command.toFullString(), PlayCommand.PLAY_NEXT);
|
||||
break;
|
||||
case PLAY_LAST_BY_TERMS_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
return;
|
||||
}
|
||||
runItemSearch(command.toFullString(), PlayCommand.PLAY_LAST);
|
||||
break;
|
||||
case BROWSE_ITEM_BY_TERMS_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
return;
|
||||
}
|
||||
runItemSearch(command.toFullString(), null);
|
||||
break;
|
||||
case PLAYING_ITEM_SECOND_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
refreshState();
|
||||
return;
|
||||
}
|
||||
if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
|
||||
return;
|
||||
}
|
||||
seekToSecond(Long.parseLong(command.toFullString()));
|
||||
break;
|
||||
case PLAYING_ITEM_PERCENTAGE_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
refreshState();
|
||||
return;
|
||||
}
|
||||
if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
|
||||
return;
|
||||
}
|
||||
seekToPercentage(Integer.parseInt(command.toFullString()));
|
||||
break;
|
||||
case PLAYING_ITEM_NAME_CHANNEL:
|
||||
case PLAYING_ITEM_GENRES_CHANNEL:
|
||||
case PLAYING_ITEM_SEASON_CHANNEL:
|
||||
case PLAYING_ITEM_EPISODE_CHANNEL:
|
||||
case PLAYING_ITEM_SERIES_NAME_CHANNEL:
|
||||
case PLAYING_ITEM_SEASON_NAME_CHANNEL:
|
||||
case PLAYING_ITEM_TYPE_CHANNEL:
|
||||
case PLAYING_ITEM_TOTAL_SECOND_CHANNEL:
|
||||
if (command instanceof RefreshType) {
|
||||
refreshState();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (SyncCallback.SyncCallbackError syncCallbackError) {
|
||||
logger.warn("Unexpected error while running channel {}: {}", channelUID.getId(),
|
||||
syncCallbackError.getMessage());
|
||||
} catch (ApiClientException e) {
|
||||
getServerHandler().handleApiException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
cancelDelayedCommand();
|
||||
}
|
||||
|
||||
private void cancelDelayedCommand() {
|
||||
var delayedCommand = this.delayedCommand;
|
||||
if (delayedCommand != null) {
|
||||
delayedCommand.cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshState() {
|
||||
getServerHandler().updateClientState(this);
|
||||
}
|
||||
|
||||
private void updateChannelStates(@Nullable BaseItemDto playingItem, @Nullable PlayerStateInfo playState) {
|
||||
lastPlayingState = playingItem != null;
|
||||
lastRunTimeTicks = playingItem != null ? Objects.requireNonNull(playingItem.getRunTimeTicks()) : 0L;
|
||||
var positionTicks = playState != null ? playState.getPositionTicks() : null;
|
||||
var runTimeTicks = playingItem != null ? playingItem.getRunTimeTicks() : null;
|
||||
if (isLinked(MEDIA_CONTROL_CHANNEL)) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL),
|
||||
playingItem != null && playState != null && !playState.isPaused() ? PlayPauseType.PLAY
|
||||
: PlayPauseType.PAUSE);
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_PERCENTAGE_CHANNEL)) {
|
||||
if (positionTicks != null && runTimeTicks != null) {
|
||||
int percentage = (int) Math.round((positionTicks * 100.0) / runTimeTicks);
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_PERCENTAGE_CHANNEL),
|
||||
new PercentType(percentage));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_PERCENTAGE_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_SECOND_CHANNEL)) {
|
||||
if (positionTicks != null) {
|
||||
var second = Math.round((float) positionTicks / 10000000.0);
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SECOND_CHANNEL), new DecimalType(second));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_SECOND_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_TOTAL_SECOND_CHANNEL)) {
|
||||
if (runTimeTicks != null) {
|
||||
var seconds = Math.round((float) runTimeTicks / 10000000.0);
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TOTAL_SECOND_CHANNEL),
|
||||
new DecimalType(seconds));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_TOTAL_SECOND_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_NAME_CHANNEL)) {
|
||||
if (playingItem != null) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_NAME_CHANNEL),
|
||||
new StringType(playingItem.getName()));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_NAME_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_SERIES_NAME_CHANNEL)) {
|
||||
if (playingItem != null) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SERIES_NAME_CHANNEL),
|
||||
new StringType(playingItem.getSeriesName()));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_SERIES_NAME_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_SEASON_NAME_CHANNEL)) {
|
||||
if (playingItem != null && "Episode".equals(playingItem.getType())) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_NAME_CHANNEL),
|
||||
new StringType(playingItem.getSeasonName()));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_SEASON_NAME_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_SEASON_CHANNEL)) {
|
||||
if (playingItem != null && "Episode".equals(playingItem.getType())) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_CHANNEL),
|
||||
new DecimalType(Objects.requireNonNull(playingItem.getParentIndexNumber())));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_SEASON_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_EPISODE_CHANNEL)) {
|
||||
if (playingItem != null && "Episode".equals(playingItem.getType())) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_EPISODE_CHANNEL),
|
||||
new DecimalType(Objects.requireNonNull(playingItem.getIndexNumber())));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_EPISODE_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_GENRES_CHANNEL)) {
|
||||
if (playingItem != null) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_GENRES_CHANNEL),
|
||||
new StringType(String.join(",", Objects.requireNonNull(playingItem.getGenres()))));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_GENRES_CHANNEL);
|
||||
}
|
||||
}
|
||||
if (isLinked(PLAYING_ITEM_TYPE_CHANNEL)) {
|
||||
if (playingItem != null) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TYPE_CHANNEL),
|
||||
new StringType(playingItem.getType()));
|
||||
} else {
|
||||
cleanChannel(PLAYING_ITEM_TYPE_CHANNEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runItemSearch(String terms, @Nullable PlayCommand playCommand)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (terms.isBlank() || UnDefType.NULL.toFullString().equals(terms)) {
|
||||
return;
|
||||
}
|
||||
// detect series search with season and episode info
|
||||
var seriesEpisodeMatcher = seriesSearchPattern.matcher(terms);
|
||||
if (seriesEpisodeMatcher.matches()) {
|
||||
var season = Integer.parseInt(seriesEpisodeMatcher.group("season"));
|
||||
var episode = Integer.parseInt(seriesEpisodeMatcher.group("episode"));
|
||||
var cleanTerms = seriesEpisodeMatcher.group("terms");
|
||||
runSeriesEpisode(cleanTerms, season, episode, playCommand);
|
||||
return;
|
||||
}
|
||||
// detect search with type info or consider all types are enabled
|
||||
var typeMatcher = typeSearchPattern.matcher(terms);
|
||||
boolean searchByTypeEnabled = typeMatcher.matches();
|
||||
var type = searchByTypeEnabled ? typeMatcher.group("type") : "";
|
||||
boolean movieSearchEnabled = !searchByTypeEnabled || type.equals("movie");
|
||||
boolean seriesSearchEnabled = !searchByTypeEnabled || type.equals("series");
|
||||
boolean episodeSearchEnabled = !searchByTypeEnabled || type.equals("episode");
|
||||
var searchTerms = searchByTypeEnabled ? typeMatcher.group("terms") : terms;
|
||||
runItemSearchByType(searchTerms, playCommand, movieSearchEnabled, seriesSearchEnabled, episodeSearchEnabled);
|
||||
}
|
||||
|
||||
private void runItemSearchByType(String terms, @Nullable PlayCommand playCommand, boolean movieSearchEnabled,
|
||||
boolean seriesSearchEnabled, boolean episodeSearchEnabled)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var seriesItem = seriesSearchEnabled ? getServerHandler().searchItem(terms, "Series", null) : null;
|
||||
var movieItem = movieSearchEnabled ? getServerHandler().searchItem(terms, "Movie", null) : null;
|
||||
var episodeItem = episodeSearchEnabled ? getServerHandler().searchItem(terms, "Episode", null) : null;
|
||||
if (movieItem != null) {
|
||||
logger.debug("Found movie: '{}'", movieItem.getName());
|
||||
}
|
||||
if (seriesItem != null) {
|
||||
logger.debug("Found series: '{}'", seriesItem.getName());
|
||||
}
|
||||
if (episodeItem != null) {
|
||||
logger.debug("Found episode: '{}'", episodeItem.getName());
|
||||
}
|
||||
if (movieItem != null) {
|
||||
runItem(movieItem, playCommand);
|
||||
} else if (seriesItem != null) {
|
||||
if (playCommand != null) {
|
||||
var resumeEpisodeItem = getServerHandler().getSeriesResumeItem(seriesItem.getId());
|
||||
var nextUpEpisodeItem = getServerHandler().getSeriesNextUpItem(seriesItem.getId());
|
||||
var firstEpisodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), 1, 1);
|
||||
if (resumeEpisodeItem != null) {
|
||||
logger.debug("Resuming series '{}' episode '{}'", seriesItem.getName(),
|
||||
resumeEpisodeItem.getName());
|
||||
playItem(resumeEpisodeItem, playCommand,
|
||||
Objects.requireNonNull(resumeEpisodeItem.getUserData()).getPlaybackPositionTicks());
|
||||
} else if (nextUpEpisodeItem != null) {
|
||||
logger.debug("Playing next series '{}' episode '{}'", seriesItem.getName(),
|
||||
nextUpEpisodeItem.getName());
|
||||
playItem(nextUpEpisodeItem, playCommand);
|
||||
} else if (firstEpisodeItem != null) {
|
||||
logger.debug("Playing series '{}' first episode '{}'", seriesItem.getName(),
|
||||
firstEpisodeItem.getName());
|
||||
playItem(firstEpisodeItem, playCommand);
|
||||
} else {
|
||||
logger.warn("Unable to found episode for series");
|
||||
}
|
||||
} else {
|
||||
logger.debug("Browse series '{}'", seriesItem.getName());
|
||||
browseItem(seriesItem);
|
||||
}
|
||||
} else if (episodeItem != null) {
|
||||
runItem(episodeItem, playCommand);
|
||||
} else {
|
||||
logger.warn("Nothing to display for: {}", terms);
|
||||
}
|
||||
}
|
||||
|
||||
private void runSeriesEpisode(String terms, int season, int episode, @Nullable PlayCommand playCommand)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
logger.debug("{} series episode mode", playCommand != null ? "Play" : "Browse");
|
||||
var seriesItem = getServerHandler().searchItem(terms, "Series", null);
|
||||
if (seriesItem != null) {
|
||||
logger.debug("Searching series {} episode {}x{}", seriesItem.getName(), season, episode);
|
||||
var episodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), season, episode);
|
||||
if (episodeItem != null) {
|
||||
runItem(episodeItem, playCommand);
|
||||
} else {
|
||||
logger.warn("Series {} episode {}x{} not found", seriesItem.getName(), season, episode);
|
||||
}
|
||||
} else {
|
||||
logger.warn("Series not found");
|
||||
}
|
||||
}
|
||||
|
||||
private void runItem(BaseItemDto item, @Nullable PlayCommand playCommand)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var itemType = Objects.requireNonNull(item.getType());
|
||||
logger.debug("{} {} '{}'", playCommand == null ? "Browsing" : "Playing", itemType.toLowerCase(),
|
||||
"Episode".equals(itemType) ? item.getSeriesName() + ": " + item.getName() : item.getName());
|
||||
if (playCommand == null) {
|
||||
browseItem(item);
|
||||
} else {
|
||||
playItem(item, playCommand);
|
||||
}
|
||||
}
|
||||
|
||||
private void playItem(BaseItemDto item, PlayCommand playCommand)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
playItem(item, playCommand, null);
|
||||
}
|
||||
|
||||
private void playItem(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (playCommand.equals(PlayCommand.PLAY_NOW) && stopCurrentPlayback()) {
|
||||
cancelDelayedCommand();
|
||||
delayedCommand = scheduler.schedule(() -> {
|
||||
try {
|
||||
playItemInternal(item, playCommand, startPositionTicks);
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
logger.warn("Unexpected error while running channel {}: {}", PLAY_BY_TERMS_CHANNEL, e.getMessage());
|
||||
}
|
||||
}, 3, TimeUnit.SECONDS);
|
||||
} else {
|
||||
playItemInternal(item, playCommand, startPositionTicks);
|
||||
}
|
||||
}
|
||||
|
||||
private void playItemInternal(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
getServerHandler().playItem(lastSessionId, playCommand, item.getId().toString(), startPositionTicks);
|
||||
}
|
||||
|
||||
private void browseItem(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (stopCurrentPlayback()) {
|
||||
cancelDelayedCommand();
|
||||
delayedCommand = scheduler.schedule(() -> {
|
||||
try {
|
||||
browseItemInternal(item);
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
logger.warn("Unexpected error while running channel {}: {}", BROWSE_ITEM_BY_TERMS_CHANNEL,
|
||||
e.getMessage());
|
||||
}
|
||||
}, 3, TimeUnit.SECONDS);
|
||||
} else {
|
||||
browseItemInternal(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void browseItemInternal(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
getServerHandler().browseToItem(lastSessionId, Objects.requireNonNull(item.getType()), item.getId().toString(),
|
||||
Objects.requireNonNull(item.getName()));
|
||||
}
|
||||
|
||||
private boolean stopCurrentPlayback() throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (lastPlayingState) {
|
||||
sendPlayStateCommand(PlaystateCommand.STOP);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sendPlayStateCommand(PlaystateCommand command)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
sendPlayStateCommand(command, null);
|
||||
}
|
||||
|
||||
private void sendPlayStateCommand(PlaystateCommand command, @Nullable Long seekPositionTick)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
getServerHandler().sendPlayStateCommand(lastSessionId, command, seekPositionTick);
|
||||
}
|
||||
|
||||
private void sendDeviceMessage(Command command) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
getServerHandler().sendDeviceMessage(lastSessionId, "Jellyfin OpenHAB", command.toFullString(), 15000);
|
||||
}
|
||||
|
||||
private void handleMediaControlCommand(ChannelUID channelUID, Command command)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (command instanceof RefreshType) {
|
||||
refreshState();
|
||||
} else if (command instanceof PlayPauseType) {
|
||||
if (command == PlayPauseType.PLAY) {
|
||||
sendPlayStateCommand(PlaystateCommand.UNPAUSE);
|
||||
updateState(channelUID, PlayPauseType.PLAY);
|
||||
} else if (command == PlayPauseType.PAUSE) {
|
||||
sendPlayStateCommand(PlaystateCommand.PAUSE);
|
||||
updateState(channelUID, PlayPauseType.PAUSE);
|
||||
}
|
||||
} else if (command instanceof NextPreviousType) {
|
||||
if (command == NextPreviousType.NEXT) {
|
||||
sendPlayStateCommand(PlaystateCommand.NEXT_TRACK);
|
||||
} else if (command == NextPreviousType.PREVIOUS) {
|
||||
sendPlayStateCommand(PlaystateCommand.PREVIOUS_TRACK);
|
||||
}
|
||||
} else if (command instanceof RewindFastforwardType) {
|
||||
if (command == RewindFastforwardType.FASTFORWARD) {
|
||||
sendPlayStateCommand(PlaystateCommand.FAST_FORWARD);
|
||||
} else if (command == RewindFastforwardType.REWIND) {
|
||||
sendPlayStateCommand(PlaystateCommand.REWIND);
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown media control command: {}", command);
|
||||
}
|
||||
}
|
||||
|
||||
private void seekToPercentage(int percentage) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
if (lastRunTimeTicks == 0L) {
|
||||
logger.warn("Can't seek missing RunTimeTicks info");
|
||||
return;
|
||||
}
|
||||
var seekPositionTick = Math.round(((float) lastRunTimeTicks) * ((float) percentage / 100.0));
|
||||
logger.debug("Seek to {}%: {} of {}", percentage, seekPositionTick, lastRunTimeTicks);
|
||||
seekToTick(seekPositionTick);
|
||||
}
|
||||
|
||||
private void seekToSecond(long second) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
long seekPositionTick = second * 10000000L;
|
||||
logger.debug("Seek to second {}: {} of {}", second, seekPositionTick, lastRunTimeTicks);
|
||||
seekToTick(seekPositionTick);
|
||||
}
|
||||
|
||||
private void seekToTick(long seekPositionTick) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
sendPlayStateCommand(PlaystateCommand.SEEK, seekPositionTick);
|
||||
scheduler.schedule(this::refreshState, 3, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void cleanChannels() {
|
||||
List.of(MEDIA_CONTROL_CHANNEL, PLAYING_ITEM_PERCENTAGE_CHANNEL, PLAYING_ITEM_NAME_CHANNEL,
|
||||
PLAYING_ITEM_SERIES_NAME_CHANNEL, PLAYING_ITEM_SEASON_NAME_CHANNEL, PLAYING_ITEM_SEASON_CHANNEL,
|
||||
PLAYING_ITEM_EPISODE_CHANNEL, PLAYING_ITEM_GENRES_CHANNEL, PLAYING_ITEM_TYPE_CHANNEL,
|
||||
PLAYING_ITEM_SECOND_CHANNEL, PLAYING_ITEM_TOTAL_SECOND_CHANNEL).forEach(this::cleanChannel);
|
||||
}
|
||||
|
||||
private void cleanChannel(String channelId) {
|
||||
updateState(new ChannelUID(this.thing.getUID(), channelId), UnDefType.NULL);
|
||||
}
|
||||
|
||||
private JellyfinServerHandler getServerHandler() {
|
||||
var bridge = Objects.requireNonNull(getBridge());
|
||||
return (JellyfinServerHandler) Objects.requireNonNull(bridge.getHandler());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.handler;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jellyfin.sdk.Jellyfin;
|
||||
import org.jellyfin.sdk.JellyfinOptions;
|
||||
import org.jellyfin.sdk.api.client.ApiClient;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException;
|
||||
import org.jellyfin.sdk.api.client.exception.MissingUserIdException;
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi;
|
||||
import org.jellyfin.sdk.api.operations.SessionApi;
|
||||
import org.jellyfin.sdk.api.operations.SystemApi;
|
||||
import org.jellyfin.sdk.api.operations.TvShowsApi;
|
||||
import org.jellyfin.sdk.api.operations.UserApi;
|
||||
import org.jellyfin.sdk.model.ClientInfo;
|
||||
import org.jellyfin.sdk.model.api.AuthenticateUserByName;
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult;
|
||||
import org.jellyfin.sdk.model.api.ItemFields;
|
||||
import org.jellyfin.sdk.model.api.MessageCommand;
|
||||
import org.jellyfin.sdk.model.api.PlayCommand;
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand;
|
||||
import org.jellyfin.sdk.model.api.SessionInfo;
|
||||
import org.jellyfin.sdk.model.api.SystemInfo;
|
||||
import org.openhab.binding.jellyfin.internal.JellyfinServerConfiguration;
|
||||
import org.openhab.binding.jellyfin.internal.discovery.JellyfinClientDiscoveryService;
|
||||
import org.openhab.binding.jellyfin.internal.util.EmptySyncResponse;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncCallback;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncResponse;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.openhab.core.cache.ExpiringCache;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinServerHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinServerHandler extends BaseBridgeHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinServerHandler.class);
|
||||
private final ApiClient jellyApiClient;
|
||||
private final ExpiringCache<List<SessionInfo>> sessionsCache = new ExpiringCache<>(
|
||||
Duration.of(1, ChronoUnit.SECONDS), this::tryGetSessions);
|
||||
private JellyfinServerConfiguration config = new JellyfinServerConfiguration();
|
||||
private @Nullable ScheduledFuture<?> checkInterval;
|
||||
|
||||
public JellyfinServerHandler(Bridge bridge) {
|
||||
super(bridge);
|
||||
var options = new JellyfinOptions.Builder();
|
||||
options.setClientInfo(new ClientInfo("openHAB", OpenHAB.getVersion()));
|
||||
options.setDeviceInfo(new org.jellyfin.sdk.model.DeviceInfo(getThing().getUID().getId(), "openHAB"));
|
||||
jellyApiClient = new Jellyfin(options.build()).createApi();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(JellyfinServerConfiguration.class);
|
||||
jellyApiClient.setBaseUrl(getServerUrl());
|
||||
if (config.token.isBlank() || config.userId.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
|
||||
"Navigate to <your local openhab url>/jellyfin/" + this.getThing().getUID().getId() + " to login.");
|
||||
return;
|
||||
}
|
||||
jellyApiClient.setAccessToken(config.token);
|
||||
jellyApiClient.setUserId(UUID.fromString(config.userId));
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
startChecker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
stopChecker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return Collections.singleton(JellyfinClientDiscoveryService.class);
|
||||
}
|
||||
|
||||
public String getServerUrl() {
|
||||
return (config.ssl ? "https" : "http") + "://" + config.hostname + ":" + config.port;
|
||||
}
|
||||
|
||||
public boolean isOnline() {
|
||||
var asyncResponse = new SyncResponse<String>();
|
||||
new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
|
||||
try {
|
||||
return asyncResponse.awaitResponse().getStatus() == 200;
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
logger.warn("Response error: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAuthenticated() {
|
||||
if (config.token.isBlank() || config.userId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
var asyncResponse = new SyncResponse<SystemInfo>();
|
||||
new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
|
||||
try {
|
||||
var systemInfo = asyncResponse.awaitContent();
|
||||
var properties = editProperties();
|
||||
var productName = systemInfo.getProductName();
|
||||
if (productName != null) {
|
||||
properties.put(Thing.PROPERTY_VENDOR, productName);
|
||||
}
|
||||
var version = systemInfo.getVersion();
|
||||
if (version != null) {
|
||||
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
|
||||
}
|
||||
updateProperties(properties);
|
||||
return true;
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public JellyfinCredentials login(String user, String password)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncCall = new SyncResponse<AuthenticationResult>();
|
||||
new UserApi(jellyApiClient).authenticateUserByName(new AuthenticateUserByName(user, password, null), asyncCall);
|
||||
var authResult = asyncCall.awaitContent();
|
||||
var token = Objects.requireNonNull(authResult.getAccessToken());
|
||||
var userId = Objects.requireNonNull(authResult.getUser()).getId().toString();
|
||||
return new JellyfinCredentials(token, userId);
|
||||
}
|
||||
|
||||
public void updateCredentials(JellyfinCredentials credentials) {
|
||||
var currentConfig = getConfig();
|
||||
currentConfig.put("token", credentials.getAccessToken());
|
||||
currentConfig.put("userId", credentials.getUserId());
|
||||
updateConfiguration(currentConfig);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void updateStatusUnauthenticated() {
|
||||
sessionsCache.invalidateValue();
|
||||
updateClients(List.of());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Authentication failed. Navigate to <your local openhab url>/jellyfin/"
|
||||
+ this.getThing().getUID().getId() + " to login again.");
|
||||
}
|
||||
|
||||
private void checkClientStates() {
|
||||
var sessions = sessionsCache.getValue();
|
||||
if (sessions != null) {
|
||||
logger.debug("Got {} sessions", sessions.size());
|
||||
updateClients(sessions);
|
||||
} else {
|
||||
sessionsCache.invalidateValue();
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable List<SessionInfo> tryGetSessions() {
|
||||
try {
|
||||
if (jellyApiClient.getAccessToken() == null) {
|
||||
return null;
|
||||
}
|
||||
var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
|
||||
: null;
|
||||
return getControllableSessions(clientActiveWithInSeconds);
|
||||
} catch (SyncCallback.SyncCallbackError syncCallbackError) {
|
||||
logger.warn("Unexpected error while running channel calling server: {}", syncCallbackError.getMessage());
|
||||
} catch (ApiClientException e) {
|
||||
handleApiException(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void handleApiException(ApiClientException e) {
|
||||
logger.warn("Api error: {}", e.getMessage());
|
||||
var cause = e.getCause();
|
||||
boolean unauthenticated = false;
|
||||
if (cause instanceof InvalidStatusException) {
|
||||
var status = ((InvalidStatusException) cause).getStatus();
|
||||
if (status == 401) {
|
||||
unauthenticated = true;
|
||||
}
|
||||
logger.warn("Api error has invalid status: {}", status);
|
||||
}
|
||||
if (cause instanceof MissingUserIdException) {
|
||||
unauthenticated = true;
|
||||
}
|
||||
if (unauthenticated) {
|
||||
updateStatusUnauthenticated();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateClientState(JellyfinClientHandler handler) {
|
||||
var sessions = sessionsCache.getValue();
|
||||
if (sessions != null) {
|
||||
updateClientState(handler, sessions);
|
||||
} else {
|
||||
sessionsCache.invalidateValue();
|
||||
}
|
||||
}
|
||||
|
||||
public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
return getControllableSessions(null);
|
||||
}
|
||||
|
||||
public List<SessionInfo> getControllableSessions(@Nullable Integer activeWithInSeconds)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncContinuation = new SyncResponse<List<SessionInfo>>();
|
||||
new SessionApi(jellyApiClient).getSessions(this.jellyApiClient.getUserId(), null, activeWithInSeconds,
|
||||
asyncContinuation);
|
||||
return asyncContinuation.awaitContent();
|
||||
}
|
||||
|
||||
public void sendPlayStateCommand(String sessionId, PlaystateCommand command, @Nullable Long seekPositionTicks)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var awaiter = new EmptySyncResponse();
|
||||
new SessionApi(jellyApiClient).sendPlaystateCommand(sessionId, command, seekPositionTicks,
|
||||
Objects.requireNonNull(jellyApiClient.getUserId()).toString(), awaiter);
|
||||
awaiter.awaitResponse();
|
||||
}
|
||||
|
||||
public void sendDeviceMessage(String sessionId, String header, String text, long ms)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var awaiter = new EmptySyncResponse();
|
||||
new SessionApi(jellyApiClient).sendMessageCommand(sessionId, new MessageCommand(header, text, ms), awaiter);
|
||||
awaiter.awaitResponse();
|
||||
}
|
||||
|
||||
public void playItem(String sessionId, PlayCommand playCommand, String itemId, @Nullable Long startPositionTicks)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var awaiter = new EmptySyncResponse();
|
||||
new SessionApi(jellyApiClient).play(sessionId, playCommand, List.of(UUID.fromString(itemId)),
|
||||
startPositionTicks, null, null, null, null, awaiter);
|
||||
awaiter.awaitResponse();
|
||||
}
|
||||
|
||||
public void browseToItem(String sessionId, String itemType, String itemId, String itemName)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var awaiter = new EmptySyncResponse();
|
||||
new SessionApi(jellyApiClient).displayContent(sessionId, itemType, itemId, itemName, awaiter);
|
||||
awaiter.awaitResponse();
|
||||
}
|
||||
|
||||
public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public List<BaseItemDto> getSeriesNextUpItems(UUID seriesId, int limit)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
|
||||
new TvShowsApi(jellyApiClient).getNextUp(jellyApiClient.getUserId(), null, limit, null, seriesId.toString(),
|
||||
null, null, null, null, null, null, null, asyncContinuation);
|
||||
var result = asyncContinuation.awaitContent();
|
||||
return Objects.requireNonNull(result.getItems());
|
||||
}
|
||||
|
||||
public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public List<BaseItemDto> getSeriesResumeItems(UUID seriesId, int limit)
|
||||
throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
|
||||
new ItemsApi(jellyApiClient).getResumeItems(Objects.requireNonNull(jellyApiClient.getUserId()), null, limit,
|
||||
null, seriesId, null, null, true, null, null, null, List.of("Episode"), null, null, asyncContinuation);
|
||||
var result = asyncContinuation.awaitContent();
|
||||
return Objects.requireNonNull(result.getItems());
|
||||
}
|
||||
|
||||
public @Nullable BaseItemDto getSeriesEpisodeItem(UUID seriesId, @Nullable Integer season,
|
||||
@Nullable Integer episode) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
return getSeriesEpisodeItems(seriesId, season, episode, 1).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public List<BaseItemDto> getSeriesEpisodeItems(UUID seriesId, @Nullable Integer season, @Nullable Integer episode,
|
||||
int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
|
||||
new TvShowsApi(jellyApiClient).getEpisodes(seriesId, jellyApiClient.getUserId(), null, season, null, null, null,
|
||||
null, episode != null ? episode - 1 : null, limit, null, null, null, null, null, asyncContinuation);
|
||||
var result = asyncContinuation.awaitContent();
|
||||
return Objects.requireNonNull(result.getItems());
|
||||
}
|
||||
|
||||
public @Nullable BaseItemDto searchItem(@Nullable String searchTerm, @Nullable String itemType,
|
||||
@Nullable List<ItemFields> fields) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
return searchItems(searchTerm, itemType, fields, 1).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public List<BaseItemDto> searchItems(@Nullable String searchTerm, @Nullable String itemType,
|
||||
@Nullable List<ItemFields> fields, int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
|
||||
var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
|
||||
var itemTypes = itemType != null ? List.of(itemType) : null;
|
||||
new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, limit, true, searchTerm, null, null, fields, null, itemTypes, null, null, null, null,
|
||||
null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, false, false, asyncContinuation);
|
||||
var response = asyncContinuation.awaitContent();
|
||||
return Objects.requireNonNull(response.getItems());
|
||||
}
|
||||
|
||||
private void startChecker() {
|
||||
stopChecker();
|
||||
checkInterval = scheduler.scheduleWithFixedDelay(() -> {
|
||||
if (!isOnline()) {
|
||||
updateStatus(ThingStatus.OFFLINE);
|
||||
return;
|
||||
} else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
|
||||
if (!isAuthenticated()) {
|
||||
updateStatusUnauthenticated();
|
||||
return;
|
||||
}
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
checkClientStates();
|
||||
}, 0, config.refreshSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void stopChecker() {
|
||||
var checkInterval = this.checkInterval;
|
||||
if (checkInterval != null) {
|
||||
checkInterval.cancel(true);
|
||||
this.checkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateClients(List<SessionInfo> sessions) {
|
||||
var things = getThing().getThings();
|
||||
things.forEach((childThing) -> {
|
||||
var handler = childThing.getHandler();
|
||||
if (handler == null) {
|
||||
return;
|
||||
}
|
||||
if (handler instanceof JellyfinClientHandler) {
|
||||
updateClientState((JellyfinClientHandler) handler, sessions);
|
||||
} else {
|
||||
logger.warn("Found unknown thing-handler instance: {}", handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
|
||||
@Nullable
|
||||
SessionInfo clientSession = sessions.stream()
|
||||
.filter(session -> Objects.equals(session.getDeviceId(), handler.getThing().getUID().getId()))
|
||||
.findFirst().orElse(null);
|
||||
handler.updateStateFromSession(clientSession);
|
||||
}
|
||||
|
||||
public static class JellyfinCredentials {
|
||||
private final String accessToken;
|
||||
private final String userId;
|
||||
|
||||
public JellyfinCredentials(String accessToken, String userId) {
|
||||
this.accessToken = accessToken;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.servlet;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.openhab.binding.jellyfin.internal.handler.JellyfinServerHandler;
|
||||
import org.openhab.binding.jellyfin.internal.util.SyncCallback;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link JellyfinBridgeServlet} is responsible for handling user login.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class JellyfinBridgeServlet extends HttpServlet {
|
||||
private final Logger logger = LoggerFactory.getLogger(JellyfinBridgeServlet.class);
|
||||
private static final long serialVersionUID = 2157912759968949550L;
|
||||
private final JellyfinServerHandler server;
|
||||
|
||||
public JellyfinBridgeServlet(JellyfinServerHandler server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
String requestUri = req.getRequestURI();
|
||||
if (requestUri == null) {
|
||||
return;
|
||||
}
|
||||
String user = req.getParameter("username");
|
||||
String password = req.getParameter("password");
|
||||
if (user != null && password != null && !user.isBlank() && !password.isBlank()) {
|
||||
try {
|
||||
server.updateCredentials(server.login(user, password));
|
||||
} catch (SyncCallback.SyncCallbackError | ApiClientException e) {
|
||||
logger.warn("Server error while login: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
String newUri = req.getServletPath() + "/";
|
||||
resp.sendRedirect(newUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
String requestUri = req.getRequestURI();
|
||||
if (requestUri == null) {
|
||||
return;
|
||||
}
|
||||
String uri = requestUri.substring(this.getServletContext().getContextPath().length());
|
||||
logger.debug("doGet {}", uri);
|
||||
if (!uri.endsWith("/")) {
|
||||
String newUri = req.getServletPath() + "/";
|
||||
resp.sendRedirect(newUri);
|
||||
return;
|
||||
}
|
||||
String serverUrl = server.getServerUrl();
|
||||
String label = server.getThing().getLabel();
|
||||
String serverName = label != null ? label : "Jellyfin Binding";
|
||||
boolean online = server.isOnline();
|
||||
boolean authenticated = online && server.isAuthenticated();
|
||||
String html = renderPage(serverUrl, serverName, online, authenticated);
|
||||
resp.addHeader("content-type", "text/html;charset=UTF-8");
|
||||
try {
|
||||
resp.getWriter().write(html);
|
||||
} catch (IOException e) {
|
||||
logger.warn("return html failed with uri syntax error", e);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String renderPage(String serverUrl, String serverName, boolean online, boolean authenticated) {
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<html><head><title>OpenHAB Jellyfin Binding</title><style>");
|
||||
// css
|
||||
html.append(
|
||||
"*{box-sizing:border-box}body{background-color:#101010;font-family:Arial,sans-serif;padding:50px}.container{margin:20px auto;padding:10px;padding-bottom:0px;width:300px;background-color:#fff;border-radius:5px}h1{color:#777;font-size:32px;margin:15px auto;text-align:center}form{text-align:center}input{padding:12px 0;margin-bottom:10px;border-radius:3px;border:2px solid transparent;text-align:center;width:90%;font-size:16px;transition:border .2s,background-color .2s}form .field{background-color:#ecf0f1}form .field:focus{border:2px solid #3498db}form .btn{background-color:#00a4dc;color:#fff;line-height:25px;cursor:pointer}form .btn:active,form .btn:hover{background-color:#1f78b4;border:2px solid #1f78b4}.pass-link{text-align:center}.pass-link a:link,.pass-link a:visited{font-size:12px;color:#777} .status{padding-bottom: 18px;}");
|
||||
html.append(
|
||||
".oh-logo{background-image: url(/images/openhab-logo.svg);background-size: 89px;background-repeat: no-repeat;height: 44px; width: 144px; margin-left: 40px;}");
|
||||
html.append(".logo{background-image: url(").append(serverUrl).append(
|
||||
"/web/assets/img/banner-light.png);background-size: 140px;margin: 10px auto;background-repeat: no-repeat;width: 44px;height: 44px;}");
|
||||
html.append("</style></head><body>");
|
||||
// open container
|
||||
html.append("<div class=\"container\">");
|
||||
// add logos and title
|
||||
html.append("<h2 class=\"logo\"><p class=\"oh-logo\"><p></h2><h1>").append(serverName).append("</h1>");
|
||||
if (online) {
|
||||
if (!authenticated) {
|
||||
// add form
|
||||
html.append(
|
||||
"<form action=\"#\" method=\"POST\"><input name=\"username\" type=\"text\" placeholder=\"username\" class=\"field\"><input name=\"password\" type=\"password\" placeholder=\"password\" class=\"field\"><input type=\"submit\" value=\"Login\" class=\"btn\"></form>");
|
||||
} else {
|
||||
html.append("<h1 class=\"status\">✅ Connected</h1>");
|
||||
}
|
||||
} else {
|
||||
html.append("<h1 class=\"status\">❌ Offline</h1>");
|
||||
}
|
||||
// close container
|
||||
html.append("</div>");
|
||||
// add server link
|
||||
html.append("<div class=\"pass-link\"><a target=\"_blank\" href=\"").append(serverUrl)
|
||||
.append("\" >Server Url: ").append(serverUrl).append("</a></div>");
|
||||
html.append("</body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.util;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* The {@link EmptySyncResponse} util to consume util to consume sdk api calls with no content.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EmptySyncResponse extends SyncResponse<Unit> {
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.util;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.jellyfin.sdk.compatibility.JavaContinuation;
|
||||
|
||||
/**
|
||||
* The {@link SyncCallback} util to consume kotlin suspend functions.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public abstract class SyncCallback<T> extends JavaContinuation<@Nullable T> {
|
||||
private final CountDownLatch latch;
|
||||
@Nullable
|
||||
private T result;
|
||||
@Nullable
|
||||
private Throwable error;
|
||||
|
||||
protected SyncCallback() {
|
||||
latch = new CountDownLatch(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess(@Nullable T result) {
|
||||
this.result = result;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable Throwable error) {
|
||||
this.error = error;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
public T awaitResult() throws SyncCallbackError {
|
||||
return awaitResult(10);
|
||||
}
|
||||
|
||||
public T awaitResult(int timeoutSecs) throws SyncCallbackError {
|
||||
try {
|
||||
if (!latch.await(timeoutSecs, TimeUnit.SECONDS)) {
|
||||
throw new SyncCallbackError("Execution timeout");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new SyncCallbackError(e);
|
||||
}
|
||||
var error = this.error;
|
||||
if (error != null) {
|
||||
throw new SyncCallbackError(error);
|
||||
}
|
||||
var result = this.result;
|
||||
if (result == null) {
|
||||
throw new SyncCallbackError("Missing result");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static class SyncCallbackError extends Exception {
|
||||
private static final long serialVersionUID = 2157912759968949551L;
|
||||
|
||||
protected SyncCallbackError(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
protected SyncCallbackError(Throwable original) {
|
||||
super(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2022 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.jellyfin.internal.util;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.jellyfin.sdk.api.client.Response;
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException;
|
||||
|
||||
/**
|
||||
* The {@link SyncResponse} util to consume sdk api calls.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SyncResponse<T> extends SyncCallback<Response<T>> {
|
||||
public Response<T> awaitResponse() throws ApiClientException, SyncCallbackError {
|
||||
try {
|
||||
return awaitResult();
|
||||
} catch (SyncCallbackError e) {
|
||||
var cause = e.getCause();
|
||||
if (cause instanceof ApiClientException) {
|
||||
throw (ApiClientException) cause;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public T awaitContent() throws ApiClientException, SyncCallbackError {
|
||||
var responseContent = awaitResponse().getContent();
|
||||
if (responseContent == null) {
|
||||
throw new SyncCallbackError("Missing content");
|
||||
}
|
||||
return responseContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<binding:binding id="jellyfin" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
|
||||
|
||||
<name>Jellyfin Binding</name>
|
||||
<description>This is the binding for Jellyfin the free software media system.</description>
|
||||
|
||||
</binding:binding>
|
||||
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="jellyfin"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
<bridge-type id="server">
|
||||
<label>Jellyfin Server</label>
|
||||
<description>Represents a running Jellyfin server instance</description>
|
||||
<config-description>
|
||||
<parameter name="hostname" type="text" required="true">
|
||||
<context>network-address</context>
|
||||
<label>Hostname/IP</label>
|
||||
<description>Hostname or IP address of the server</description>
|
||||
<default>127.0.0.1</default>
|
||||
</parameter>
|
||||
<parameter name="port" type="integer" min="0" max="65535" required="true">
|
||||
<label>Port</label>
|
||||
<description>Port of the server</description>
|
||||
<default>8096</default>
|
||||
</parameter>
|
||||
<parameter name="ssl" type="boolean" required="true">
|
||||
<label>SSL</label>
|
||||
<description>Connect through https</description>
|
||||
<default>false</default>
|
||||
</parameter>
|
||||
<parameter name="refreshSeconds" type="integer" min="10" max="300" required="true">
|
||||
<label>Refresh Seconds</label>
|
||||
<description>Interval to pull devices state from the server</description>
|
||||
<default>30</default>
|
||||
</parameter>
|
||||
<parameter name="clientActiveWithInSeconds" type="integer" min="0" max="950" required="true">
|
||||
<label>Client Active Timeout</label>
|
||||
<description>Amount off seconds allowed since the last client activity to assert it's online (0 disabled)</description>
|
||||
<default>0</default>
|
||||
</parameter>
|
||||
<parameter name="userId" type="text">
|
||||
<label>User ID</label>
|
||||
<description>The user id</description>
|
||||
</parameter>
|
||||
<parameter name="token" type="text">
|
||||
<label>Access Token</label>
|
||||
<description>The user access token</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</bridge-type>
|
||||
<!-- Sample Thing Type -->
|
||||
<thing-type id="client">
|
||||
<supported-bridge-type-refs>
|
||||
<bridge-type-ref id="server"/>
|
||||
</supported-bridge-type-refs>
|
||||
|
||||
<label>Jellyfin Client</label>
|
||||
<description>Represents a running Jellyfin client connected to a server</description>
|
||||
|
||||
<channels>
|
||||
<channel id="send-notification" typeId="send-notification-channel"/>
|
||||
<channel id="media-control" typeId="system.media-control"/>
|
||||
<channel id="playing-item-name" typeId="playing-item-name-channel"/>
|
||||
<channel id="playing-item-series-name" typeId="playing-item-series-name-channel"/>
|
||||
<channel id="playing-item-season-name" typeId="playing-item-season-name-channel"/>
|
||||
<channel id="playing-item-season" typeId="playing-item-season-channel"/>
|
||||
<channel id="playing-item-episode" typeId="playing-item-episode-channel"/>
|
||||
<channel id="playing-item-genders" typeId="playing-item-genders-channel"/>
|
||||
<channel id="playing-item-type" typeId="playing-item-type-channel"/>
|
||||
<channel id="playing-item-percentage" typeId="playing-item-percentage-channel"/>
|
||||
<channel id="playing-item-second" typeId="playing-item-second-channel"/>
|
||||
<channel id="playing-item-total-seconds" typeId="playing-item-total-seconds-channel"/>
|
||||
<channel id="play-by-terms" typeId="play-by-terms-channel"/>
|
||||
<channel id="play-next-by-terms" typeId="play-next-by-terms-channel"/>
|
||||
<channel id="play-last-by-terms" typeId="play-last-by-terms-channel"/>
|
||||
<channel id="browse-by-terms" typeId="browse-by-terms-channel"/>
|
||||
</channels>
|
||||
|
||||
<config-description>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<!-- Client Channels -->
|
||||
<channel-type id="send-notification-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Send Notification</label>
|
||||
<description>Send notification to the client</description>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-name-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Playing Item Name</label>
|
||||
<description>Name of the item currently playing</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-series-name-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Playing Item Series Name</label>
|
||||
<description>Name of the item's series currently playing, only have value when item is an episode</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-season-name-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Playing Item Season Name</label>
|
||||
<description>Name of the item's season currently playing, only have value when item is an episode</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-season-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Playing Item Season</label>
|
||||
<description>Number of the item's season currently playing, only have value when item is an episode</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-episode-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Playing Item Episode</label>
|
||||
<description>Number of the episode item currently playing, only have value when item is an episode</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-genders-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Playing Item Genders</label>
|
||||
<description>Coma separate list genders of the item currently playing</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-type-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Playing Item Type</label>
|
||||
<description>Type of the item currently playing</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-percentage-channel">
|
||||
<item-type>Dimmer</item-type>
|
||||
<label>Playing Item Percentage</label>
|
||||
<description>Played percentage for the item currently playing, allow seek</description>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-second-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Playing Item Second</label>
|
||||
<description>Current second for the item currently playing, allow seek</description>
|
||||
</channel-type>
|
||||
<channel-type id="playing-item-total-seconds-channel">
|
||||
<item-type>Number</item-type>
|
||||
<label>Playing Item Total Seconds</label>
|
||||
<description>Total seconds for the item currently playing</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
<channel-type id="play-by-terms-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Play By Terms</label>
|
||||
<description>Play media by terms, works for series, episodes and movies</description>
|
||||
</channel-type>
|
||||
<channel-type id="play-next-by-terms-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Play Next By Terms</label>
|
||||
<description>Add to playback queue as next by terms; works for series, episodes and movies</description>
|
||||
</channel-type>
|
||||
<channel-type id="play-last-by-terms-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Play Last By Terms</label>
|
||||
<description>Add to playback queue as last by terms; works for series, episodes and movies</description>
|
||||
</channel-type>
|
||||
<channel-type id="browse-by-terms-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Browse By Terms</label>
|
||||
<description>Browse media by terms, works for series, episodes and movies</description>
|
||||
</channel-type>
|
||||
</thing:thing-descriptions>
|
||||
Reference in New Issue
Block a user