[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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2160 additions and 0 deletions

View File

@ -146,6 +146,7 @@
/bundles/org.openhab.binding.ism8/ @hans-reiner
/bundles/org.openhab.binding.jablotron/ @octa22
/bundles/org.openhab.binding.jeelink/ @vbier
/bundles/org.openhab.binding.jellyfin/ @GiviMAD
/bundles/org.openhab.binding.kaleidescape/ @mlobstein
/bundles/org.openhab.binding.keba/ @kgoderis
/bundles/org.openhab.binding.km200/ @Markinus

View File

@ -721,6 +721,11 @@
<artifactId>org.openhab.binding.jeelink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.jellyfin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.kaleidescape</artifactId>

View File

@ -0,0 +1,30 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
== Third-party Content
org.jellyfin.sdk.jellyfin-core-jvm
* License: MIT License
* Project: https://github.com/jellyfin/jellyfin-sdk-kotlin
* Source: https://github.com/jellyfin/jellyfin-sdk-kotlin/jellyfin-core-jvm
org.jellyfin.sdk.jellyfin-api-jvm
* License: MIT License
* Project: https://github.com/jellyfin/jellyfin-sdk-kotlin
* Source: https://github.com/jellyfin/jellyfin-sdk-kotlin/jellyfin-api-jvm
org.jellyfin.sdk.jellyfin-model-jvm
* License: MIT License
* Project: https://github.com/jellyfin/jellyfin-sdk-kotlin
* Source: https://github.com/jellyfin/jellyfin-sdk-kotlin/jellyfin-model-jvm

View File

@ -0,0 +1,123 @@
# Jellyfin Binding
This is the binding for [Jellyfin](https://jellyfin.org) the volunteer-built media solution that puts you in control of your media.
Stream to any device from your own server, with no strings attached.
Your media, your server, your way.
This binding allows connect to Jellyfin clients that supports remote control, it's build on top of the official Jellyfin kotlin sdk.
## Supported Things
This binding was tested against the android tv, and web clients.
The only problem that I found is that the channels play-next-by-terms and play-last-by-terms don't work currently on the android tv client.
Before open an issue please test you are able to correctly control your device from the Jellyfin web ui to identify whetter is an issue on the client itself.
## Discovery
Before you are able to discover clients you should have a bridge to the server so until one is online the discovery will only look for servers on your local network. Once one is online the discovery will detect controllable clients connected to that server.
## Thing Types
| ThingTypeID | description |
|----------|------------------------------|
| server (bridge) | Jellyfin server instance |
| client | Jellyfin controllable client instance |
## Authentication
To allow the server thing to go online you should provide valid credentials for the user that the biding will use to interact with the server api (userId and token configuration properties).
Please note that the user should be allowed on the Jellyfin server to remote control devices.
In order to assist you with this process the binding expose a simple login form you can access on \<local openHAB server url\>/jellyfin/\<server thing id\> for example http://127.0.0.1:8080/jellyfin/2846b8fb60ad444f9ebd085335e3f6bf.
## Server Thing Configuration
| Config | Type | description |
|----------|----------|------------------------------|
| hostname | text | Hostname or IP address of the server (required) |
| port | integer | Port of the server (required) |
| ssl | boolean | Connect through https (required) |
| refreshSeconds | integer | Interval to pull devices state from the server |
| clientActiveWithInSeconds | integer | Amount off seconds allowed since the last client activity to assert it's online (0 disabled) |
| userId | text | The user id |
| token | text | The user access token |
## Channels
| channel | type | description |
|----------|--------|------------------------------|
| send-notification | String | Display message in client |
| media-control | Player | Control media playback |
| playing-item-name | String | Name of the item currently playing (readonly) |
| playing-item-series-name | String | Name of the item's series currently playing, only have value when item is an episode (readonly) |
| playing-item-season-name | String | Name of the item's season currently playing, only have value when item is an episode (readonly) |
| playing-item-season | Number | Number of the item's season currently playing, only have value when item is an episode (readonly) |
| playing-item-episode | Number | Number of the episode item currently playing, only have value when item is an episode (readonly) |
| playing-item-genders | String | Coma separate list genders of the item currently playing (readonly) |
| playing-item-type | String | Type of the item currently playing (readonly) |
| playing-item-percentage | Dimmer | Played percentage for the item currently playing, allow seek |
| playing-item-second | Number | Current second for the item currently playing, allow seek |
| playing-item-total-seconds | Number | Total seconds for the item currently playing (readonly) |
| play-by-terms | String | Play media by terms, works for series, episodes and movies; terms search is explained bellow |
| play-next-by-terms | String | Add to playback queue as next by terms, works for series, episodes and movies; terms search is explained bellow |
| play-last-by-terms | String | Add to playback queue as last by terms, works for series, episodes and movies; terms search is explained bellow |
| browse-by-terms | String | Browse media by terms, works for series, episodes and movies; terms search is explained bellow |
### Terms search:
The terms search has a default behavior that can be modified sending some predefined prefixes.
The default behavior will look for movies, series, or episodes whose name start with the provided text, if it found results the prevalence go as said before.
If the result is a series the binding will try to resume some episode, if not it will look for the next episode to watch and finally will fall back to the first episode.
You can prefix your search with '\<type:movie\>', '\<type:episode\>', '\<type:series\>' to restrict your search to a given type.
Also, you can target a specific series episode by season and episode numbers prefixing your search with '\<season:1\>\<episode:1\>' with the desired values. So '\<season:3\>\<episode:10\>Something' will try to play the episode 10 for the season 3 of the series named 'Something'.
## Full Example
### Example Server (Bridge) - jellyfin.bridge.things
```
Bridge jellyfin:server:exampleServerId "Jellyfin Server" [
clientActiveWithInSeconds=0,
hostname="192.168.1.177",
port=8096,
refreshSeconds=30,
ssl="false"
token=XXXXX # Optional, read bellow
userId=XXXXX # Optional, read bellow
]
```
* token and userId could be retrieved using the login form at http://YOUROPENHABIP:PORT/jellyfin/exampleServerId
### Example Client - jellyfin.clients.things
```
Thing jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID> "Jellyfin Web client" (jellyfin:server:exampleServerId)
Thing jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID> "Jellyfin Android client" (jellyfin:server:exampleServerId)
```
* I recommend creating the clients using the discovery. For getting the device ids manually I recommend to use the Jellyfin web interface with the web inspector and look for the request that is launched when you click the cast button (<jellyfin url>/Sessions?ControllableByUserId=XXXXXXXXXXXX).
### Example Items - jellyfin.items
```
String strJellyfinAndroidSendNotification { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:send-notification " }
Player plJellyfinAndroidMediaControl { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:media-control" }
String strJellyfinAndroidPlayingItemName { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-name" }
String strJellyfinAndroidPlayingItemSeriesName { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-series-name" }
String strJellyfinAndroidPlayingItemSeasonName { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-season-name" }
Number nJellyfinAndroidPlayingItemSeason { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-season" }
Number nJellyfinAndroidPlpayingItemEpisode { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-episode" }
String strJellyfinAndroidPlayingItemGenders { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-genders" }
String strJellyfinAndroidPlayingItemType { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-type" }
Dimmer dJellyfinAndroidPlayingItemPercentage { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-percentage" }
Number nJellyfinAndroidPlayingItemSecond { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-second" }
Number nJellyfinAndroidPlayingItemTotalSeconds { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:playing-item-total-seconds" }
String strJellyfinAndroidPlayByTerms { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:play-by-terms" }
String strJellyfinAndroidPlayByNextTerms { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:play-next-by-terms" }
String strJellyfinAndroidPlayByLastTerms { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:play-last-by-terms" }
String strJellyfinAndroidBrowseByTerms { channel="jellyfin:client:exampleServerId:<JELLYFIN_DEVICE_ID>:browse-by-terms" }
```

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.3.0-SNAPSHOT</version>
</parent>
<properties>
<bnd.importpackage>
!android.*,!com.android.*,!kotlin.internal.jdk7,!kotlin.internal.jdk8,!kotlin.reflect.*,!okhttp3.*,!okio.*
</bnd.importpackage>
</properties>
<artifactId>org.openhab.binding.jellyfin</artifactId>
<name>openHAB Add-ons :: Bundles :: Jellyfin Binding</name>
<dependencies>
<dependency>
<groupId>org.jellyfin.sdk</groupId>
<artifactId>jellyfin-core-jvm</artifactId>
<version>1.2.0</version>
</dependency>
<dependency>
<groupId>org.jellyfin.sdk</groupId>
<artifactId>jellyfin-api-jvm</artifactId>
<version>1.2.0</version>
</dependency>
<dependency>
<groupId>org.jellyfin.sdk</groupId>
<artifactId>jellyfin-model-jvm</artifactId>
<version>1.2.0</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-core-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-cio-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-http-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-http-cio-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-network-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-utils-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-io-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-network-tls-jvm</artifactId>
<version>1.6.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>1.6.10</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
<version>1.5.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-jdk8</artifactId>
<version>1.5.2</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-core-jvm</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json-jvm</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>io.github.microutils</groupId>
<artifactId>kotlin-logging-jvm</artifactId>
<version>2.1.16</version>
</dependency>
</dependencies>
</project>

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>

View File

@ -178,6 +178,7 @@
<module>org.openhab.binding.ism8</module>
<module>org.openhab.binding.jablotron</module>
<module>org.openhab.binding.jeelink</module>
<module>org.openhab.binding.jellyfin</module>
<module>org.openhab.binding.kaleidescape</module>
<module>org.openhab.binding.keba</module>
<module>org.openhab.binding.km200</module>