From e6628cf63a5fc5a1de765fcb01c7c0766af7af63 Mon Sep 17 00:00:00 2001 From: GiviMAD Date: Sat, 30 Apr 2022 10:58:14 +0200 Subject: [PATCH] [jellyfin] initial contribution (#11939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [jellyfin] initial contribution Signed-off-by: Miguel Álvarez Díez * [jellyfin] update parent version Signed-off-by: Miguel Álvarez Díez * update license header year Signed-off-by: Miguel Álvarez Díez * add example to readme Signed-off-by: Miguel Álvarez Díez * apply pr review Signed-off-by: Miguel Álvarez Díez * apply pr review Signed-off-by: Miguel Álvarez Díez * apply pr review Signed-off-by: Miguel Álvarez Díez * add third-party info Signed-off-by: Miguel Álvarez Díez * upgrade sdk to release 1.2.0 Signed-off-by: Miguel Álvarez Díez --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.jellyfin/NOTICE | 30 + .../org.openhab.binding.jellyfin/README.md | 123 ++++ bundles/org.openhab.binding.jellyfin/pom.xml | 117 ++++ .../src/main/feature/feature.xml | 9 + .../internal/JellyfinBindingConstants.java | 56 ++ .../internal/JellyfinHandlerFactory.java | 113 ++++ .../internal/JellyfinServerConfiguration.java | 52 ++ .../JellyfinClientDiscoveryService.java | 126 +++++ .../JellyfinServerDiscoveryService.java | 134 +++++ .../handler/JellyfinClientHandler.java | 534 ++++++++++++++++++ .../handler/JellyfinServerHandler.java | 407 +++++++++++++ .../servlet/JellyfinBridgeServlet.java | 125 ++++ .../internal/util/EmptySyncResponse.java | 26 + .../jellyfin/internal/util/SyncCallback.java | 85 +++ .../jellyfin/internal/util/SyncResponse.java | 45 ++ .../main/resources/OH-INF/binding/binding.xml | 9 + .../resources/OH-INF/thing/thing-types.xml | 162 ++++++ bundles/pom.xml | 1 + 20 files changed, 2160 insertions(+) create mode 100644 bundles/org.openhab.binding.jellyfin/NOTICE create mode 100644 bundles/org.openhab.binding.jellyfin/README.md create mode 100644 bundles/org.openhab.binding.jellyfin/pom.xml create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinBindingConstants.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinHandlerFactory.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinServerConfiguration.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinClientDiscoveryService.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinServerDiscoveryService.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinClientHandler.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinServerHandler.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/servlet/JellyfinBridgeServlet.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/EmptySyncResponse.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncCallback.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncResponse.java create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/thing/thing-types.xml diff --git a/CODEOWNERS b/CODEOWNERS index 2250a960fa9..27a1cc88c75 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 7176056bb0d..f28e8b7bbe8 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -721,6 +721,11 @@ org.openhab.binding.jeelink ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.jellyfin + ${project.version} + org.openhab.addons.bundles org.openhab.binding.kaleidescape diff --git a/bundles/org.openhab.binding.jellyfin/NOTICE b/bundles/org.openhab.binding.jellyfin/NOTICE new file mode 100644 index 00000000000..5692f43c208 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/NOTICE @@ -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 diff --git a/bundles/org.openhab.binding.jellyfin/README.md b/bundles/org.openhab.binding.jellyfin/README.md new file mode 100644 index 00000000000..70c7ba8bb06 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/README.md @@ -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 \/jellyfin/\ 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 '\', '\', '\' 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 '\\' with the desired values. So '\\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 Web client" (jellyfin:server:exampleServerId) +Thing jellyfin:client:exampleServerId: "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 (/Sessions?ControllableByUserId=XXXXXXXXXXXX). + +### Example Items - jellyfin.items + +``` +String strJellyfinAndroidSendNotification { channel="jellyfin:client:exampleServerId::send-notification " } +Player plJellyfinAndroidMediaControl { channel="jellyfin:client:exampleServerId::media-control" } +String strJellyfinAndroidPlayingItemName { channel="jellyfin:client:exampleServerId::playing-item-name" } +String strJellyfinAndroidPlayingItemSeriesName { channel="jellyfin:client:exampleServerId::playing-item-series-name" } +String strJellyfinAndroidPlayingItemSeasonName { channel="jellyfin:client:exampleServerId::playing-item-season-name" } +Number nJellyfinAndroidPlayingItemSeason { channel="jellyfin:client:exampleServerId::playing-item-season" } +Number nJellyfinAndroidPlpayingItemEpisode { channel="jellyfin:client:exampleServerId::playing-item-episode" } +String strJellyfinAndroidPlayingItemGenders { channel="jellyfin:client:exampleServerId::playing-item-genders" } +String strJellyfinAndroidPlayingItemType { channel="jellyfin:client:exampleServerId::playing-item-type" } +Dimmer dJellyfinAndroidPlayingItemPercentage { channel="jellyfin:client:exampleServerId::playing-item-percentage" } +Number nJellyfinAndroidPlayingItemSecond { channel="jellyfin:client:exampleServerId::playing-item-second" } +Number nJellyfinAndroidPlayingItemTotalSeconds { channel="jellyfin:client:exampleServerId::playing-item-total-seconds" } +String strJellyfinAndroidPlayByTerms { channel="jellyfin:client:exampleServerId::play-by-terms" } +String strJellyfinAndroidPlayByNextTerms { channel="jellyfin:client:exampleServerId::play-next-by-terms" } +String strJellyfinAndroidPlayByLastTerms { channel="jellyfin:client:exampleServerId::play-last-by-terms" } +String strJellyfinAndroidBrowseByTerms { channel="jellyfin:client:exampleServerId::browse-by-terms" } +``` diff --git a/bundles/org.openhab.binding.jellyfin/pom.xml b/bundles/org.openhab.binding.jellyfin/pom.xml new file mode 100644 index 00000000000..45d630456a2 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/pom.xml @@ -0,0 +1,117 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.3.0-SNAPSHOT + + + + !android.*,!com.android.*,!kotlin.internal.jdk7,!kotlin.internal.jdk8,!kotlin.reflect.*,!okhttp3.*,!okio.* + + + org.openhab.binding.jellyfin + + openHAB Add-ons :: Bundles :: Jellyfin Binding + + + org.jellyfin.sdk + jellyfin-core-jvm + 1.2.0 + + + org.jellyfin.sdk + jellyfin-api-jvm + 1.2.0 + + + org.jellyfin.sdk + jellyfin-model-jvm + 1.2.0 + + + io.ktor + ktor-client-core-jvm + 1.6.7 + compile + + + io.ktor + ktor-client-cio-jvm + 1.6.7 + compile + + + io.ktor + ktor-http-jvm + 1.6.7 + compile + + + io.ktor + ktor-http-cio-jvm + 1.6.7 + compile + + + io.ktor + ktor-network-jvm + 1.6.7 + compile + + + io.ktor + ktor-utils-jvm + 1.6.7 + compile + + + io.ktor + ktor-io-jvm + 1.6.7 + compile + + + io.ktor + ktor-network-tls-jvm + 1.6.7 + compile + + + org.jetbrains.kotlin + kotlin-stdlib + 1.6.10 + compile + + + org.jetbrains.kotlinx + kotlinx-coroutines-core-jvm + 1.5.2 + compile + + + org.jetbrains.kotlinx + kotlinx-coroutines-jdk8 + 1.5.2 + + + org.jetbrains.kotlinx + kotlinx-serialization-core-jvm + 1.3.1 + + + org.jetbrains.kotlinx + kotlinx-serialization-json-jvm + 1.3.1 + + + io.github.microutils + kotlin-logging-jvm + 2.1.16 + + + diff --git a/bundles/org.openhab.binding.jellyfin/src/main/feature/feature.xml b/bundles/org.openhab.binding.jellyfin/src/main/feature/feature.xml new file mode 100644 index 00000000000..96218ab5cf8 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.jellyfin/${project.version} + + diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinBindingConstants.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinBindingConstants.java new file mode 100644 index 00000000000..9a06098281a --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinBindingConstants.java @@ -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 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; +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinHandlerFactory.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinHandlerFactory.java new file mode 100644 index 00000000000..9ff78550e16 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinHandlerFactory.java @@ -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 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(); + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinServerConfiguration.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinServerConfiguration.java new file mode 100644 index 00000000000..bedee67e2de --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/JellyfinServerConfiguration.java @@ -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 = ""; +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinClientDiscoveryService.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinClientDiscoveryService.java new file mode 100644 index 00000000000..56059f15644 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinClientDiscoveryService.java @@ -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 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(); + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinServerDiscoveryService.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinServerDiscoveryService.java new file mode 100644 index 00000000000..3bbc4b7709a --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/discovery/JellyfinServerDiscoveryService.java @@ -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(); + 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 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()); + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinClientHandler.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinClientHandler.java new file mode 100644 index 00000000000..87c6160e0fb --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinClientHandler.java @@ -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("movie|series|episode)>\\s?(?.*)"); + private final Pattern seriesSearchPattern = Pattern + .compile("()?[0-9]*)>[0-9]*)>\\s?(?.*)"); + 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()); + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinServerHandler.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinServerHandler.java new file mode 100644 index 00000000000..5e0a3aefdc8 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/handler/JellyfinServerHandler.java @@ -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> 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 /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> 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(); + 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(); + 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(); + 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 /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 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 getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException { + return getControllableSessions(null); + } + + public List getControllableSessions(@Nullable Integer activeWithInSeconds) + throws SyncCallback.SyncCallbackError, ApiClientException { + var asyncContinuation = new SyncResponse>(); + 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 getSeriesNextUpItems(UUID seriesId, int limit) + throws SyncCallback.SyncCallbackError, ApiClientException { + var asyncContinuation = new SyncResponse(); + 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 getSeriesResumeItems(UUID seriesId, int limit) + throws SyncCallback.SyncCallbackError, ApiClientException { + var asyncContinuation = new SyncResponse(); + 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 getSeriesEpisodeItems(UUID seriesId, @Nullable Integer season, @Nullable Integer episode, + int limit) throws SyncCallback.SyncCallbackError, ApiClientException { + var asyncContinuation = new SyncResponse(); + 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 fields) throws SyncCallback.SyncCallbackError, ApiClientException { + return searchItems(searchTerm, itemType, fields, 1).stream().findFirst().orElse(null); + } + + public List searchItems(@Nullable String searchTerm, @Nullable String itemType, + @Nullable List fields, int limit) throws SyncCallback.SyncCallbackError, ApiClientException { + var asyncContinuation = new SyncResponse(); + 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 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 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; + } + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/servlet/JellyfinBridgeServlet.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/servlet/JellyfinBridgeServlet.java new file mode 100644 index 00000000000..ec566c34e96 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/servlet/JellyfinBridgeServlet.java @@ -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("OpenHAB Jellyfin Binding"); + // open container + html.append("
"); + // add logos and title + html.append("

").append(serverName).append("

"); + if (online) { + if (!authenticated) { + // add form + html.append( + "
"); + } else { + html.append("

✅ Connected

"); + } + } else { + html.append("

❌ Offline

"); + } + // close container + html.append("
"); + // add server link + html.append(""); + html.append(""); + return html.toString(); + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/EmptySyncResponse.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/EmptySyncResponse.java new file mode 100644 index 00000000000..5fbcbbe8817 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/EmptySyncResponse.java @@ -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 { +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncCallback.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncCallback.java new file mode 100644 index 00000000000..672bdd57ff1 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncCallback.java @@ -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 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); + } + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncResponse.java b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncResponse.java new file mode 100644 index 00000000000..a48e517e8ba --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/java/org/openhab/binding/jellyfin/internal/util/SyncResponse.java @@ -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 extends SyncCallback> { + public Response 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; + } +} diff --git a/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 00000000000..8229ad8c3c4 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + Jellyfin Binding + This is the binding for Jellyfin the free software media system. + + diff --git a/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..ace51e62ec7 --- /dev/null +++ b/bundles/org.openhab.binding.jellyfin/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,162 @@ + + + + + Represents a running Jellyfin server instance + + + network-address + + Hostname or IP address of the server + 127.0.0.1 + + + + Port of the server + 8096 + + + + Connect through https + false + + + + Interval to pull devices state from the server + 30 + + + + Amount off seconds allowed since the last client activity to assert it's online (0 disabled) + 0 + + + + The user id + + + + The user access token + + + + + + + + + + + Represents a running Jellyfin client connected to a server + + + + + + + + + + + + + + + + + + + + + + + + + + + String + + Send notification to the client + + + String + + Name of the item currently playing + + + + String + + Name of the item's series currently playing, only have value when item is an episode + + + + String + + Name of the item's season currently playing, only have value when item is an episode + + + + Number + + Number of the item's season currently playing, only have value when item is an episode + + + + Number + + Number of the episode item currently playing, only have value when item is an episode + + + + String + + Coma separate list genders of the item currently playing + + + + String + + Type of the item currently playing + + + + Dimmer + + Played percentage for the item currently playing, allow seek + + + Number + + Current second for the item currently playing, allow seek + + + Number + + Total seconds for the item currently playing + + + + String + + Play media by terms, works for series, episodes and movies + + + String + + Add to playback queue as next by terms; works for series, episodes and movies + + + String + + Add to playback queue as last by terms; works for series, episodes and movies + + + String + + Browse media by terms, works for series, episodes and movies + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 503073988da..66d4eccca40 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -178,6 +178,7 @@ org.openhab.binding.ism8 org.openhab.binding.jablotron org.openhab.binding.jeelink + org.openhab.binding.jellyfin org.openhab.binding.kaleidescape org.openhab.binding.keba org.openhab.binding.km200