[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:
GiviMAD
2022-04-30 10:58:14 +02:00
committed by GitHub
parent ae20f93f19
commit e6628cf63a
20 changed files with 2160 additions and 0 deletions

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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 = "";
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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> {
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>