diff --git a/CODEOWNERS b/CODEOWNERS
index 2acfcd6ac72..06fc47d3909 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -104,6 +104,7 @@
/bundles/org.openhab.binding.electroluxappliance/ @jannegpriv
/bundles/org.openhab.binding.elerotransmitterstick/ @vbier
/bundles/org.openhab.binding.elroconnects/ @mherwege
+/bundles/org.openhab.binding.emby/ @volfan6415
/bundles/org.openhab.binding.emotiva/ @espenaf
/bundles/org.openhab.binding.energenie/ @hmerk
/bundles/org.openhab.binding.energidataservice/ @jlaur
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 19439b3b7d2..8ee1624743b 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -506,6 +506,11 @@
org.openhab.binding.elroconnects
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.emby
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.emotiva
diff --git a/bundles/org.openhab.binding.emby/NOTICE b/bundles/org.openhab.binding.emby/NOTICE
new file mode 100644
index 00000000000..d339c4f8b77
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/NOTICE
@@ -0,0 +1,13 @@
+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
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.emby/README.md b/bundles/org.openhab.binding.emby/README.md
new file mode 100644
index 00000000000..03ad31caa21
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/README.md
@@ -0,0 +1,164 @@
+# Emby Binding
+
+The **Emby Binding** integrates [Emby](https://emby.media/), a personal media server, with openHAB.
+
+It allows controlling Emby players and retrieving player status data.
+For example, you can monitor the currently playing movie title or automatically dim your lights when playback starts.
+This binding supports multiple Emby clients connected to a single Emby Media Server.
+It provides functionality similar to the Plex Binding.
+
+## Supported Things
+
+This binding defines the following Thing Type IDs:
+
+- `controller`
+ Represents a connection to an Emby server (a Bridge Thing).
+
+- `device`
+ Represents a client/player device connected to the Emby server.
+
+## Automatic Discovery
+
+The binding supports automatic discovery for both servers (`controller`) and clients (`device`).
+
+## Binding Configuration
+
+There is no global binding-level configuration required or supported.
+
+## Thing Configuration
+
+### `controller` Bridge Configuration
+
+The following Configuration Parameter Keys are available:
+
+| Name | Type | Description | Default | Required | Advanced |
+|-----------------|---------|---------------------------------------------------------|---------|----------|----------|
+| ipAddress | Text | IP address or hostname of the Emby server. | N/A | Yes | No |
+| api | Text | API Key generated from Emby for authorization. | N/A | Yes | No |
+| bufferSize | Integer | WebSocket buffer size in bytes. | 10,000 | No | No |
+| refreshInterval | Integer | Polling interval for play-state updates (milliseconds). | 10,000 | No | No |
+| port | Integer | Port in which EMBY is listening for communication. | 8096 | No | No |
+| discovery | Boolean | Enable or disable automatic device discovery. | true | No | Yes |
+
+### `device` Thing Configuration
+
+The following Configuration Parameter Key is available:
+
+- `deviceID`
+ The unique identifier for the client device connected to the Emby server.
+
+## Channels
+
+The following Channel IDs are available for a `device` Thing:
+
+| Channel ID | Item Type | Config Parameters | Description |
+|--------------|-------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------|
+| control | Player | None | Playback control (play, pause, next, previous, fast-forward, rewind). |
+| stop | Switch | None | Indicates playback state; OFF stops playback. |
+| title | String | None | Title of the currently playing song. |
+| show-title | String | None | Title of the currently playing movie or TV show. |
+| mute | Switch | None | Mute status control. |
+| image-url | String | imageUrlMaxHeight, imageMaxWidth, imageUrlType, imageUrlPercentPlayed | URL for current media artwork. |
+| current-time | Number:Time | None | Current playback position. |
+| duration | Number:Time | None | Total media duration. |
+| media-type | String | None | Type of media (e.g., Movie, Episode). |
+
+## `image-url` Config Parameters
+
+| Parameter Name | Type | Default | Description |
+|-------------------------|---------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `imageUrlType` | Text | Primary | Specifies the image type to retrieve. Options include `Primary`, `Art`, `Backdrop`, `Banner`, `Logo`, `Thumb`, `Disc`, `Box`, `Screenshot`, `Menu`, and `Chapter`. |
+| `imageUrlMaxHeight` | Text | None | The maximum height (in pixels) of the retrieved image. |
+| `imageUrlMaxWidth` | Text | None | The maximum width (in pixels) of the retrieved image. |
+| `imageUrlPercentPlayed` | Boolean | false | If true, adds an overlay indicating the percent played (e.g., 47%). |
+
+
+## Full Example
+
+### `emby.things` Example
+
+```java
+Bridge emby:controller:myEmbyServer [
+ ipAddress="192.168.1.100",
+ api="YOUR_EMBY_API_KEY",
+ bufferSize=16384,
+ refreshInterval=2000,
+ discovery=true
+] {
+ Thing emby:device:myClientDevice [
+ deviceID="YOUR_CLIENT_DEVICE_ID"
+ ]
+}
+```
+
+### `emby.items` Example
+
+```java
+Switch Emby_PlayPause "Play/Pause" { channel="emby:device:myEmbyServer:myClientDevice:control" }
+Switch Emby_Stop "Stop" { channel="emby:device:myEmbyServer:myClientDevice:stop" }
+Switch Emby_Mute "Mute" { channel="emby:device:myEmbyServer:myClientDevice:mute" }
+String Emby_Title "Title [%s]" { channel="emby:device:myEmbyServer:myClientDevice:title" }
+String Emby_ShowTitle "Show Title [%s]" { channel="emby:device:myEmbyServer:myClientDevice:show-title" }
+Number:Time Emby_CurrentTime "Current Time [%d %unit%]" { channel="emby:device:myEmbyServer:myClientDevice:current-time" }
+Number:Time Emby_Duration "Duration [%d %unit%]" { channel="emby:device:myEmbyServer:myClientDevice:duration" }
+String Emby_MediaType "Media Type [%s]" { channel="emby:device:myEmbyServer:myClientDevice:media-type" }
+String Emby_ImageURL "Artwork URL [%s]" { channel="emby:device:myEmbyServer:myClientDevice:image-url" }
+```
+
+### `emby.sitemap` Configuration Example
+
+```perl
+sitemap emby label="Emby Control"
+{
+ Frame label="Controls" {
+ Switch item=Emby_PlayPause
+ Switch item=Emby_Stop
+ Switch item=Emby_Mute
+ }
+ Frame label="Now Playing" {
+ Text item=Emby_Title
+ Text item=Emby_ShowTitle
+ Text item=Emby_MediaType
+ Text item=Emby_CurrentTime
+ Text item=Emby_Duration
+ Text item=Emby_ImageURL
+ }
+}
+```
+
+## Rule Actions
+
+All playback and control commands are now implemented as Rule Actions rather than channels. Use the standard `getActions` API in your rules to invoke these.
+
+### Available Actions
+
+| Action ID | Method Signature | Description |
+|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
+| sendPlay | `sendPlay(ItemIds: String, PlayCommand: String, StartPositionTicks: Integer?, MediaSourceId: String?, AudioStreamIndex: Integer?, SubtitleStreamIndex: Integer?, StartIndex: Integer?)` | Send a play command with optional parameters to an Emby player. |
+| sendGeneralCommand | `sendGeneralCommand(CommandName: String)` | Send a generic Emby control command (e.g., MoveUp, ToggleMute, GoHome). |
+| sendGeneralCommandWithArgs | `sendGeneralCommandWithArgs(CommandName: String, Arguments: String)` | Send a generic Emby control command with a JSON arguments blob (e.g., SetVolume, DisplayMessage, etc.). |
+
+### Example Rule (XTend)
+
+```xtend
+rule "Play Movie on Emby"
+when
+ Item MySwitch changed to ON
+then
+ val embyActions = getActions("emby", "emby:device:myServer:myDevice")
+ // Play item IDs "abc,def" immediately
+ embyActions.sendPlay("abc,def", "PlayNow", null, null, null, null, null)
+end
+```
+
+### Example Rule (JavaScript)
+
+```javascript
+// inside a JS Scripting rule
+let emby = actions.getActions("emby", "emby:device:myServer:myDevice");
+emby.sendGeneralCommand("ToggleMute");
+```
+
+## References
+
+- [Emby Remote Control API Documentation](https://github.com/MediaBrowser/Emby/wiki/Remote-control)
diff --git a/bundles/org.openhab.binding.emby/pom.xml b/bundles/org.openhab.binding.emby/pom.xml
new file mode 100644
index 00000000000..3b00d47cc8d
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/pom.xml
@@ -0,0 +1,15 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 5.0.0-SNAPSHOT
+
+
+ org.openhab.binding.emby
+
+
diff --git a/bundles/org.openhab.binding.emby/src/main/feature/feature.xml b/bundles/org.openhab.binding.emby/src/main/feature/feature.xml
new file mode 100644
index 00000000000..54b42199c94
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/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.emby/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java
new file mode 100644
index 00000000000..59e1e7faf0f
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link EmbyBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyBindingConstants {
+
+ private static final String BINDING_ID = "emby";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_EMBY_CONTROLLER = new ThingTypeUID(BINDING_ID, "controller");
+ public static final ThingTypeUID THING_TYPE_EMBY_DEVICE = new ThingTypeUID(BINDING_ID, "device");
+
+ // List of all Channel ids
+ public static final String CHANNEL_MUTE = "mute";
+ public static final String CHANNEL_STOP = "stop";
+ public static final String CHANNEL_CONTROL = "control";
+
+ public static final String CHANNEL_TITLE = "title";
+ public static final String CHANNEL_SHOWTITLE = "show-title";
+
+ public static final String CHANNEL_MEDIATYPE = "media-type";
+
+ public static final String CHANNEL_CURRENTTIME = "current-time";
+
+ public static final String CHANNEL_DURATION = "duration";
+ public static final String CHANNEL_IMAGEURL = "image-url";
+ public static final String CHANNEL_IMAGEURL_CONFIG_TYPE = "imageUrlType";
+ public static final String CHANNEL_IMAGEURL_CONFIG_MAXWIDTH = "imageUrlMaxWidth";
+ public static final String CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT = "imageUrlMaxHeight";
+ public static final String CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED = "imageUrlPercentPlayed";
+
+ // Module Properties
+
+ public static final String CONFIG_HOST_PARAMETER = "ipAddress";
+ public static final String CONFIG_WS_PORT_PARAMETER = "port";
+ public static final String CONFIG_REFRESH_PARAMETER = "refreshInterval";
+ public static final String CONFIG_API_KEY = "api";
+ public static final String CONFIG_DEVICE_ID = "deviceID";
+ public static final String CONFIG_DISCOVERY_ENABLE = "discovery";
+ // control constant commands
+ public static final String CONTROL_SESSION = "/Sessions/";
+ public static final String CONTROL_GENERALCOMMAND = "/Command/";
+ public static final String CONTROL_SENDPLAY = "/Playing";
+ public static final String CONTROL_PLAY = "/Playing/Unpause";
+ public static final String CONTROL_PAUSE = "/Playing/Pause";
+ public static final String CONTROL_MUTE = "/Command/Mute";
+ public static final String CONTROL_UNMUTE = "/Command/Unmute";
+ public static final String CONTROL_STOP = "/Playing/Stop";
+ public static final int CONNECTION_CHECK_INTERVAL_MS = 360000;
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java
new file mode 100644
index 00000000000..a7a086a15be
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.config.core.Configuration;
+
+/**
+ * The {@link EmbyBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @param api - This is the API key generated from EMBY used for Authorization.
+ * @param ipAddress - IP address of the EMBY Server.
+ * @param port - Port of the EMBY Server. Default is 8096.
+ * @param refreshInterval - Refresh interval in milliseconds. Default is 10,000.
+ * @param discovery - Enable/disable device auto-discovery. Default is true (enabled).
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyBridgeConfiguration extends Configuration {
+
+ public String api = "";
+ public String ipAddress = "";
+ public int port = 8096; // Default server port
+ public int refreshInterval = 10000; // Default refresh interval
+ public boolean discovery = true; // Discovery enabled by default
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.java
new file mode 100644
index 00000000000..f435595b994
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import java.util.EventListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+
+/**
+ * Interface which has to be implemented by a class in order to get status
+ * updates from a {@link EmbyConnection}
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ */
+@NonNullByDefault
+public interface EmbyBridgeListener extends EventListener {
+
+ /**
+ * Callback invoked when the connection state to the Emby server changes.
+ *
+ * @param connected {@code true} if the binding is currently connected to the Emby server,
+ * {@code false} otherwise.
+ */
+ void updateConnectionState(boolean connected);
+
+ /**
+ * Callback invoked when a playback event is received from the Emby server.
+ *
+ * @param playstate the {@link EmbyPlayStateModel} containing details about the current playback state
+ * @param hostname the hostname or IP address of the Emby server that sent the event
+ * @param embyport the port number on which the Emby server is running
+ */
+ void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport);
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java
new file mode 100644
index 00000000000..795ddc34bd4
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EmbyDeviceConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyDeviceConfiguration {
+
+ public String deviceID;
+ public String imageMaxWidth;
+ public String imageMaxHeight;
+ public boolean imagePercentPlayed;
+ public String imageImageType;
+
+ public EmbyDeviceConfiguration(String setDeviceID) {
+ deviceID = setDeviceID;
+ imageMaxWidth = "";
+ imageMaxHeight = "";
+ imagePercentPlayed = false;
+ imageImageType = "";
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java
new file mode 100644
index 00000000000..8927b51f6a4
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import java.util.EventListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+
+/**
+ * Listener interface to receive status updates from an {@link EmbyConnection}.
+ * Implementations of this interface will be notified of connection changes,
+ * playback events, and metadata updates from the Emby server.
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ */
+@NonNullByDefault
+public interface EmbyEventListener extends EventListener {
+
+ /**
+ * Enumeration of possible player states reported by the Emby server.
+ */
+ public enum EmbyState {
+ /** Playback has started or resumed. */
+ PLAY,
+ /** Playback is paused. */
+ PAUSE,
+ /** Playback has reached the end of media. */
+ END,
+ /** Playback has been stopped. */
+ STOP,
+ /** Playback is rewinding. */
+ REWIND,
+ /** Playback is fast-forwarding. */
+ FASTFORWARD
+ }
+
+ /**
+ * Enumeration of playlist modifications events reported by the Emby server.
+ */
+ public enum EmbyPlaylistState {
+ /** Item(s) are about to be added to the playlist. */
+ ADD,
+ /** Item(s) have been added to the playlist. */
+ ADDED,
+ /** Item(s) are about to be inserted into the playlist. */
+ INSERT,
+ /** Item(s) are about to be removed from the playlist. */
+ REMOVE,
+ /** Item(s) have been removed from the playlist. */
+ REMOVED,
+ /** The playlist has been cleared. */
+ CLEAR
+ }
+
+ /**
+ * Called when the connection state to the Emby server changes.
+ *
+ * @param connected true if connected to the server, false otherwise
+ */
+ void updateConnectionState(boolean connected);
+
+ /**
+ * Called when the screen saver state changes.
+ *
+ * @param screenSaveActive true if the screen saver is active, false otherwise
+ */
+ void updateScreenSaverState(boolean screenSaveActive);
+
+ /**
+ * Called when the player state changes (play, pause, stop, etc.).
+ *
+ * @param state new playback state
+ */
+ void updatePlayerState(EmbyState state);
+
+ /**
+ * Called when the media title changes.
+ *
+ * @param title the current title of the media
+ */
+ void updateTitle(String title);
+
+ /**
+ * Called when the show or series title changes.
+ *
+ * @param title the current title of the show or series
+ */
+ void updateShowTitle(String title);
+
+ /**
+ * Called when the media type changes (e.g., Movie, Episode, Song).
+ *
+ * @param mediaType the type of media currently playing
+ */
+ void updateMediaType(String mediaType);
+
+ /**
+ * Called to report the current playback position.
+ *
+ * @param currentTime playback position in milliseconds
+ */
+ void updateCurrentTime(long currentTime);
+
+ /**
+ * Called to report the total duration of the media.
+ *
+ * @param duration total duration in milliseconds
+ */
+ void updateDuration(long duration);
+
+ /**
+ * Called when the primary image URL (e.g., cover art) changes.
+ *
+ * @param imageURL URL of the new primary image
+ */
+ void updatePrimaryImageURL(String imageURL);
+
+ /**
+ * Generic handler for play state change events, providing detailed model data.
+ *
+ * @param playstate model with detailed playback state information
+ * @param hostname host name of the Emby server
+ * @param embyport port number used for the Emby connection
+ */
+ void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport);
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java
new file mode 100644
index 00000000000..0b132f58664
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal;
+
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_CONTROLLER;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_DEVICE;
+
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emby.internal.discovery.EmbyClientDiscoveryService;
+import org.openhab.binding.emby.internal.handler.EmbyBridgeHandler;
+import org.openhab.binding.emby.internal.handler.EmbyDeviceHandler;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.io.net.http.WebSocketFactory;
+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.ComponentContext;
+import org.osgi.service.component.ComponentFactory;
+import org.osgi.service.component.ComponentInstance;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EmbyHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.emby", configurationPolicy = ConfigurationPolicy.OPTIONAL, immediate = true)
+public class EmbyHandlerFactory extends BaseThingHandlerFactory {
+
+ private Logger logger = LoggerFactory.getLogger(EmbyHandlerFactory.class);
+
+ private WebSocketFactory webSocketClientFactory;
+ private TranslationProvider i18nProvider;
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Collections
+ .unmodifiableSet(Stream.of(THING_TYPE_EMBY_CONTROLLER, THING_TYPE_EMBY_DEVICE).collect(Collectors.toSet()));
+
+ @Reference(target = "(component.factory=emby:client)")
+ private @Nullable ComponentFactory discoveryFactory;
+ private final Map> discoveryInstances = new HashMap<>();
+
+ @Activate
+ public EmbyHandlerFactory(@Reference WebSocketFactory webSocketClientFactory, ComponentContext componentContext,
+ @Reference TranslationProvider i18nProvider) {
+ super.activate(componentContext);
+ this.webSocketClientFactory = webSocketClientFactory;
+ this.i18nProvider = i18nProvider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ if (THING_TYPE_EMBY_DEVICE.equals(thing.getThingTypeUID())) {
+ logger.debug("Creating EMBY Device Handler for {}.", thing.getLabel());
+ return new EmbyDeviceHandler(thing, this.i18nProvider);
+ }
+
+ if (THING_TYPE_EMBY_CONTROLLER.equals(thing.getThingTypeUID())) {
+ EmbyBridgeHandler bridgeHandler = new EmbyBridgeHandler((Bridge) thing,
+ webSocketClientFactory.getCommonWebSocketClient(), this.i18nProvider);
+ Dictionary cfg = new Hashtable<>();
+ cfg.put("bridgeUID", bridgeHandler.getThing().getUID().toString());
+
+ ComponentFactory factory = Objects.requireNonNull(discoveryFactory,
+ "discoveryFactory must be injected");
+ @SuppressWarnings("null")
+ ComponentInstance ci = factory.newInstance(cfg);
+ EmbyClientDiscoveryService discovery = (EmbyClientDiscoveryService) ci.getInstance();
+ discovery.setBridge(bridgeHandler);
+ bridgeHandler.setClientDiscoveryService(discovery);
+ discoveryInstances.put(bridgeHandler.getThing().getUID(), ci);
+
+ return bridgeHandler;
+ }
+
+ return null; // unknown thing-type
+ }
+
+ @Override
+ public void removeHandler(ThingHandler handler) {
+ ThingUID uid = handler.getThing().getUID();
+
+ ComponentInstance ci = discoveryInstances.remove(uid);
+ if (ci != null) {
+ EmbyClientDiscoveryService discovery = (EmbyClientDiscoveryService) ci.getInstance();
+
+ // undo the manual wiring done in createHandler(…)
+ if (handler instanceof EmbyBridgeHandler bridge) {
+ discovery.clearBridge(bridge);
+ }
+
+ ci.dispose(); // shuts the DS component down
+ }
+
+ super.removeHandler(handler);
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java
new file mode 100644
index 00000000000..b37b31caab8
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.discovery;
+
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_DEVICE_ID;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_HOST_PARAMETER;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_WS_PORT_PARAMETER;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_CONTROLLER;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+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.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.net.NetUtil;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+/**
+ * The {@EmbyBridgeDiscoveryService} handles the bridge discovery which finds emby servers on the network
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.embybridge")
+public class EmbyBridgeDiscoveryService extends AbstractDiscoveryService {
+
+ private static final String REQUEST_MSG = "who is EmbyServer?";
+ private static final int REQUEST_PORT = 7359;
+
+ private final Logger logger = LoggerFactory.getLogger(EmbyBridgeDiscoveryService.class);
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Collections
+ .singleton(THING_TYPE_EMBY_CONTROLLER);
+
+ public EmbyBridgeDiscoveryService() {
+ super(SUPPORTED_THING_TYPES_UIDS, 30, false);
+ }
+
+ @Override
+ public void startScan() {
+ // Find the server using UDP broadcast
+ try (DatagramSocket socket = new DatagramSocket()) {
+ socket.setBroadcast(true);
+ socket.setSoTimeout(5000);
+ byte[] sendData = REQUEST_MSG.getBytes();
+
+ // Send to 255.255.255.255 broadcast address
+ try {
+ DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length,
+ InetAddress.getByName("255.255.255.255"), REQUEST_PORT);
+ socket.send(sendPacket);
+ logger.trace(">>> Request packet sent to: {} ({})", REQUEST_MSG, REQUEST_PORT);
+ } catch (InterruptedIOException ie) {
+ // Discovery loop was interrupted—stop scanning
+ Thread.currentThread().interrupt();
+ logger.debug("Discovery interrupted, exiting");
+ }
+
+ List broadcastStrings = NetUtil.getAllBroadcastAddresses();
+ for (String broadcastStr : broadcastStrings) {
+ try {
+ InetAddress broadcast = InetAddress.getByName(broadcastStr);
+ socket.send(new DatagramPacket(sendData, sendData.length, broadcast, REQUEST_PORT));
+ logger.trace(">>> Request packet sent to: {}", broadcast.getHostAddress());
+ } catch (IOException e) {
+ logger.warn("Failed to send broadcast to {}: {}", broadcastStr, e.getMessage());
+ }
+ }
+ logger.trace(">>> Done sending broadcasts. Now waiting for a reply!");
+
+ // Wait for a response
+ byte[] recvBuf = new byte[15000];
+ DatagramPacket receivePacket = new DatagramPacket(recvBuf, recvBuf.length);
+
+ try {
+ socket.receive(receivePacket);
+
+ // We have a response
+ logger.debug(">>> Broadcast response from server: {}", receivePacket.getAddress().getHostAddress());
+ String message = new String(receivePacket.getData(), StandardCharsets.UTF_8).trim();
+ logger.debug("The message is {}", message);
+
+ final Gson gson = new Gson();
+
+ @Nullable
+ JsonObject body = gson.fromJson(message, JsonObject.class);
+ body = Objects.requireNonNull(body, "EmbyBridgeDiscoveryService: response body was null");
+
+ String serverId = body.get("Id").getAsString();
+ String serverName = body.get("Name").getAsString();
+ String serverAddress = body.get("Address").getAsString();
+
+ EmbyDeviceEncoder encoder = new EmbyDeviceEncoder();
+ serverId = encoder.encodeDeviceID(serverId);
+
+ try {
+ URI serverAddressURI = new URI(serverAddress);
+ addEMBYServer(serverAddressURI.getHost(), serverAddressURI.getPort(), serverId, serverName);
+ } catch (URISyntaxException use) {
+ logger.error("Unexpected URI syntax: {}", use.getMessage(), use);
+ throw new IllegalStateException(use);
+ }
+ } catch (SocketTimeoutException timeout) {
+ logger.debug("Socket receive timed out, no Emby server discovered");
+ }
+ } catch (IOException e) {
+ logger.warn("Exception occurred during Emby server discovery: {}", e.getMessage(), e);
+ }
+ }
+
+ public void addEMBYServer(String hostAddress, int embyPort, @Nullable String DeviceID, String Name) {
+ logger.debug("creating discovery result with address: {}:{}, for server {}", hostAddress,
+ Integer.toString(embyPort), Name);
+ ThingUID thingUID = getThingUID(DeviceID);
+ ThingTypeUID thingTypeUID = THING_TYPE_EMBY_CONTROLLER;
+
+ if (thingUID != null && DeviceID != null) {
+ Map properties = new HashMap<>();
+ properties.put(CONFIG_DEVICE_ID, DeviceID);
+ properties.put(CONFIG_HOST_PARAMETER, hostAddress);
+ properties.put(CONFIG_WS_PORT_PARAMETER, Integer.toString(embyPort));
+
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
+ .withProperties(properties).withRepresentationProperty(CONFIG_DEVICE_ID).withLabel(Name).build();
+
+ thingDiscovered(discoveryResult);
+ } else {
+ logger.debug("Unable to add {} found at {}:{} with id of {}", Name, hostAddress, embyPort, DeviceID);
+ }
+ }
+
+ private @Nullable ThingUID getThingUID(@Nullable String DeviceID) {
+ ThingTypeUID thingTypeUID = THING_TYPE_EMBY_CONTROLLER;
+ if (DeviceID != null) {
+ return new ThingUID(thingTypeUID, DeviceID);
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java
new file mode 100644
index 00000000000..a83df3fcfca
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.discovery;
+
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_DEVICE_ID;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_DEVICE;
+
+import java.util.Collections;
+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.openhab.binding.emby.internal.handler.EmbyBridgeHandler;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@EmbyClientDiscoveryService} handles the discovery of devices which are playing media on an emby server which
+ * has been setup as a bridge. This discovery service receives events from the corresponding {@EmbyBridgeHandler} that
+ * it is attached to
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@Component(service = DiscoveryService.class, factory = "emby:client", configurationPid = "discovery.embydevice", property = {
+ "discovery.interval:Integer=0", "thingTypeUIDs=emby:device" })
+@NonNullByDefault
+public class EmbyClientDiscoveryService extends AbstractDiscoveryService {
+ private final Logger logger = LoggerFactory.getLogger(EmbyClientDiscoveryService.class);
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_EMBY_DEVICE);
+ // DS will inject the one-and-only bridge handler whose thingUID matches
+ private @Nullable EmbyBridgeHandler embyBridgeHandler;
+
+ public EmbyClientDiscoveryService() {
+ super(SUPPORTED_THING_TYPES_UIDS, 0, true);
+ }
+
+ /*
+ * ------------------------------------------------------------------
+ * Called once by EmbyHandlerFactory immediately after it creates the
+ * discovery service instance.
+ * ------------------------------------------------------------------
+ */
+ public void setBridge(EmbyBridgeHandler handler) {
+ this.embyBridgeHandler = handler;
+ }
+
+ /*
+ * ------------------------------------------------------------------
+ * Called by the factory just before it disposes the ComponentInstance.
+ * ------------------------------------------------------------------
+ */
+ public void clearBridge(EmbyBridgeHandler handler) {
+ if (Objects.equals(this.embyBridgeHandler, handler)) {
+ this.embyBridgeHandler = null;
+ }
+ }
+
+ @Override
+ public void startScan() {
+ // this discovery service does not do any scanning all of the scanning is handled by the bridge handler and
+ // passed in to this service
+ }
+
+ public void addDeviceIDDiscover(EmbyPlayStateModel playstate) {
+ logger.debug("adding new emby device");
+ ThingUID thingUID = getThingUID(playstate);
+ ThingTypeUID thingTypeUID = THING_TYPE_EMBY_DEVICE;
+ EmbyDeviceEncoder encode = new EmbyDeviceEncoder();
+ String modelId = encode.encodeDeviceID(playstate.getDeviceId());
+ if (thingUID != null) {
+ ThingUID bridgeUID = requireNonNull(embyBridgeHandler,
+ "EmbyClientDiscoveryService: Bridge Handler Cannot be null").getThing().getUID();
+ Map properties = new HashMap<>(1);
+ properties.put(CONFIG_DEVICE_ID, modelId);
+ logger.debug("Disovered device {} with id {}", playstate.getDeviceName(), modelId);
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
+ .withProperties(properties).withBridge(bridgeUID).withRepresentationProperty(CONFIG_DEVICE_ID)
+ .withLabel(playstate.getDeviceName()).build();
+ thingDiscovered(discoveryResult);
+ } else {
+ logger.debug("discovered unsupported device of type '{}' and model '{}' with id {}",
+ playstate.getDeviceName(), modelId, playstate.getDeviceId());
+ }
+ }
+
+ private @Nullable ThingUID getThingUID(EmbyPlayStateModel playstate) {
+ ThingUID bridgeUID = requireNonNull(embyBridgeHandler,
+ "EmbyClientDiscoveryService: Bridge Handler Cannot be null").getThing().getUID();
+ ThingTypeUID thingTypeUID = THING_TYPE_EMBY_DEVICE;
+ return new ThingUID(thingTypeUID, bridgeUID, playstate.getDeviceId());
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java
new file mode 100644
index 00000000000..e9c568871a8
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.handler;
+
+import static java.util.Objects.requireNonNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ServiceScope;
+
+/**
+ * The {@link EmbyActions} is responsible for handling the actions which can be sent to {@link EmbyDeviceHandler}.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@Component(scope = ServiceScope.PROTOTYPE, service = EmbyActions.class)
+@ThingActionsScope(name = "emby")
+@NonNullByDefault
+public class EmbyActions implements ThingActions {
+
+ public enum EmbyPlayCommand {
+ PlayNow,
+ PlayNext,
+ PlayLast
+ }
+
+ public enum EmbyGeneralCommand {
+ MoveUp,
+ MoveDown,
+ MoveLeft,
+ MoveRight,
+ PageUp,
+ PageDown,
+ PreviousLetter,
+ NextLetter,
+ ToggleOsdMenu,
+ ToggleContextMenu,
+ ToggleMute,
+ Select,
+ Back,
+ TakeScreenshot,
+ GoHome,
+ GoToSettings,
+ VolumeUp,
+ VolumeDown,
+ ToggleFullscreen,
+ GoToSearch
+ }
+
+ public enum EmbyCommandWithArgs {
+ SetVolume,
+ SetAudioStreamIndex,
+ SetSubtitleStreamIndex,
+ DisplayContent,
+ PlayTrailers,
+ SendString,
+ DisplayMessage,
+ SetPlaybackRate,
+ SetSubtitleOffset,
+ IncrementSubtitleOffset
+ }
+
+ private @Nullable EmbyDeviceHandler handler;
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ this.handler = (EmbyDeviceHandler) handler;
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
+ }
+
+ @RuleAction(label = "@text/action.emby.SendPlay.label", description = "@text/action.emby.SendPlay.desc")
+ public void sendPlay(
+ @ActionInput(name = "itemIds", label = "@text/action.emby.SendPlay.input.itemIds.label", description = "@text/action.emby.SendPlay.input.itemIds.desc") String itemIds,
+ @ActionInput(name = "playCommand", label = "@text/action.emby.SendPlay.input.playCommand.label", description = "@text/action.emby.SendPlay.input.playCommand.desc") EmbyPlayCommand playCommand,
+ @ActionInput(name = "startPositionTicks", label = "@text/action.emby.SendPlay.input.startPositionTicks.label", description = "@text/action.emby.SendPlay.input.startPositionTicks.desc") @Nullable Integer startPositionTicks,
+ @ActionInput(name = "mediaSourceId", label = "@text/action.emby.SendPlay.input.mediaSourceId.label", description = "@text/action.emby.SendPlay.input.mediaSourceId.desc") @Nullable String mediaSourceId,
+ @ActionInput(name = "audioStreamIndex", label = "@text/action.emby.SendPlay.input.audioStreamIndex.label", description = "@text/action.emby.SendPlay.input.audioStreamIndex.desc") @Nullable Integer audioStreamIndex,
+ @ActionInput(name = "subtitleStreamIndex", label = "@text/action.emby.SendPlay.input.subtitleStreamIndex.label", description = "@text/action.emby.SendPlay.input.subtitleStreamIndex.desc") @Nullable Integer subtitleStreamIndex,
+ @ActionInput(name = "startIndex", label = "@text/action.emby.SendPlay.input.startIndex.label", description = "@text/action.emby.SendPlay.input.startIndex.desc") @Nullable Integer startIndex) {
+ requireNonNull(handler, "EmbyDeviceHandler not set").sendPlayWithParams(itemIds, playCommand.name(),
+ startPositionTicks, mediaSourceId, audioStreamIndex, subtitleStreamIndex, startIndex);
+ }
+
+ public static void sendPlay(ThingActions actions, String itemIds, EmbyPlayCommand playCommand,
+ @Nullable Integer startPositionTicks, @Nullable String mediaSourceId, @Nullable Integer audioStreamIndex,
+ @Nullable Integer subtitleStreamIndex, @Nullable Integer startIndex) {
+ if (actions instanceof EmbyActions) {
+ ((EmbyActions) actions).sendPlay(requireNonNull(itemIds), requireNonNull(playCommand), startPositionTicks,
+ mediaSourceId, audioStreamIndex, subtitleStreamIndex, startIndex);
+ } else {
+ throw new IllegalArgumentException("Not an EmbyActions instance");
+ }
+ }
+
+ @RuleAction(label = "@text/action.emby.SendGeneralCommand.label", description = "@text/action.emby.SendGeneralCommand.desc")
+ public void sendGeneralCommand(
+ @ActionInput(name = "commandName", label = "@text/action.emby.SendGeneralCommand.input.commandName.label", description = "@text/action.emby.SendGeneralCommand.input.commandName.desc") EmbyGeneralCommand commandName) {
+ requireNonNull(handler, "EmbyDeviceHandler not set").sendGeneralCommand(commandName.name());
+ }
+
+ public static void sendGeneralCommand(ThingActions actions, EmbyGeneralCommand commandName) {
+ if (actions instanceof EmbyActions embyActions) {
+ embyActions.sendGeneralCommand(commandName);
+ } else {
+ throw new IllegalArgumentException("Not an EmbyActions instance");
+ }
+ }
+
+ @RuleAction(label = "@text/action.emby.SendGeneralCommandWithArgs.label", description = "@text/action.emby.SendGeneralCommandWithArgs.desc")
+ public void sendGeneralCommandWithArgs(
+ @ActionInput(name = "commandName", label = "@text/action.emby.SendGeneralCommandWithArgs.input.commandName.label", description = "@text/action.emby.SendGeneralCommandWithArgs.input.commandName.desc") EmbyCommandWithArgs commandName,
+ @ActionInput(name = "jsonArguments", label = "@text/action.emby.SendGeneralCommandWithArgs.input.jsonArguments.label", description = "@text/action.emby.SendGeneralCommandWithArgs.input.jsonArguments.desc") String jsonArguments) {
+ requireNonNull(handler, "EmbyDeviceHandler not set").sendGeneralCommandWithArgs(commandName.name(),
+ jsonArguments);
+ }
+
+ public static void sendGeneralCommandWithArgs(ThingActions actions, EmbyCommandWithArgs commandName,
+ String jsonArguments) {
+ if (actions instanceof EmbyActions embyActions) {
+ embyActions.sendGeneralCommandWithArgs(requireNonNull(commandName), requireNonNull(jsonArguments));
+ } else {
+ throw new IllegalArgumentException("Not an EmbyActions instance");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java
new file mode 100644
index 00000000000..7a21df71973
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.handler;
+
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONNECTION_CHECK_INTERVAL_MS;
+
+import java.util.Collections;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.binding.emby.internal.EmbyBridgeConfiguration;
+import org.openhab.binding.emby.internal.EmbyBridgeListener;
+import org.openhab.binding.emby.internal.discovery.EmbyClientDiscoveryService;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+import org.openhab.binding.emby.internal.protocol.EmbyConnection;
+import org.openhab.binding.emby.internal.protocol.EmbyHTTPUtils;
+import org.openhab.binding.emby.internal.protocol.EmbyHttpRetryExceeded;
+import org.openhab.core.config.core.validation.ConfigValidationException;
+import org.openhab.core.config.core.validation.ConfigValidationMessage;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EmbyBridgeHandler} is responsible for handling commands,
+ * managing the connection to Emby and dispatching play‐state events.
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ */
+@NonNullByDefault
+public class EmbyBridgeHandler extends BaseBridgeHandler implements EmbyBridgeListener {
+
+ private final Logger logger = LoggerFactory.getLogger(EmbyBridgeHandler.class);
+
+ private volatile @Nullable EmbyConnection connection;
+ private final WebSocketClient webSocketClient;
+ private @Nullable ScheduledFuture> connectionCheckerFuture;
+ private @Nullable EmbyClientDiscoveryService clientDiscoveryService;
+ private @Nullable EmbyHTTPUtils httputils;
+ private @Nullable EmbyBridgeConfiguration config;
+ private int reconnectionCount;
+ private @Nullable String lastDiscoveryStatus;
+ private TranslationProvider i18nProvider;
+
+ public EmbyBridgeHandler(Bridge bridge, WebSocketClient webSocketClient, TranslationProvider i18nProvider) {
+ super(bridge);
+ this.webSocketClient = requireNonNull(webSocketClient, "webSocketClient must not be null");
+ this.i18nProvider = requireNonNull(i18nProvider, "translation provider must not be null");
+ }
+
+ public void sendCommand(String commandURL) {
+ logger.trace("Sending command without payload: {}", commandURL);
+ final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized");
+ try {
+ localHttpUtils.doPost(commandURL, "", 2);
+ } catch (EmbyHttpRetryExceeded e) {
+ logger.debug("Retry limit exceeded for {}", commandURL, e.getCause());
+ }
+ }
+
+ public void sendCommand(String commandURL, String payload) {
+ logger.trace("Sending command: {} with payload: {}", commandURL, payload);
+ final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized");
+ try {
+ localHttpUtils.doPost(commandURL, payload, 2);
+ } catch (EmbyHttpRetryExceeded e) {
+ logger.debug("Retry limit exceeded for {}", commandURL, e.getCause());
+ }
+ }
+
+ private void establishConnection() {
+ final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null");
+
+ exec.execute(() -> {
+ try {
+ final EmbyConnection conn = requireNonNull(this.connection, "connection must not be null");
+ final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null");
+ final String ipAddress = requireNonNull(cfg.ipAddress, "config.ipAddress must not be null");
+ final String apiKey = requireNonNull(cfg.api, "config.api must not be null");
+ final Integer refreshInterval = requireNonNull(cfg.refreshInterval,
+ "config.refreshInterval must not be null");
+ final Integer port = requireNonNull(cfg.port, "config.port must not be null");
+
+ conn.connect(ipAddress, port, apiKey, exec, refreshInterval);
+
+ this.connectionCheckerFuture = exec.scheduleWithFixedDelay(() -> {
+ if (!conn.checkConnection()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/thing.status.bridge.connectionLost");
+ }
+ }, CONNECTION_CHECK_INTERVAL_MS, CONNECTION_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS);
+ } catch (Exception e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/thing.status.bridge.connectionFailed" + e.getMessage());
+ }
+ });
+ }
+
+ @Override
+ public void initialize() {
+ updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY);
+
+ final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null");
+
+ exec.execute(() -> {
+ this.reconnectionCount = 0;
+ try {
+ this.config = checkConfiguration();
+ this.connection = new EmbyConnection(this, this.webSocketClient);
+ establishConnection();
+ updateStatus(ThingStatus.ONLINE);
+ } catch (ConfigValidationException cve) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/thing.status.bridge.configurationFailed" + cve.getMessage());
+ }
+ });
+ }
+
+ @Override
+ public void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport) {
+ final EmbyClientDiscoveryService service = this.clientDiscoveryService;
+ final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null");
+
+ if (service != null && cfg.discovery) {
+ service.addDeviceIDDiscover(playstate);
+ }
+
+ getThing().getThings().forEach(thing -> {
+ EmbyDeviceHandler handler = (EmbyDeviceHandler) thing.getHandler();
+ if (handler != null) {
+ handler.handleEvent(playstate, hostname, embyport);
+ logger.trace("Dispatched event to {}", thing.getLabel());
+ } else {
+ logger.trace("No handler for {}", thing.getLabel());
+ }
+ });
+ }
+
+ @Override
+ public void updateConnectionState(boolean connected) {
+ // Grab current status and the last detail message
+ ThingStatusInfo info = getThing().getStatusInfo();
+ ThingStatus currentStatus = info.getStatus();
+
+ if (connected) {
+ // Only transition to ONLINE if we weren’t already
+ if (currentStatus != ThingStatus.ONLINE) {
+ reconnectionCount = 0;
+ updateStatus(ThingStatus.ONLINE);
+ }
+ } else {
+ // We’ve gone offline: increment retry count and build new detail text
+ reconnectionCount++;
+ logger.debug("@text/thing.status.bridge.connectionRetry{}", reconnectionCount);
+
+ // Only emit a new OFFLINE event if status changed, or the message changed
+ boolean statusChanged = currentStatus != ThingStatus.OFFLINE;
+
+ if (statusChanged) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/thing.status.bridge.connectionRetry");
+ }
+
+ ScheduledFuture> localConnectionCheckerFuture = this.connectionCheckerFuture;
+ if (localConnectionCheckerFuture != null) {
+ localConnectionCheckerFuture.cancel(false);
+ this.connectionCheckerFuture = null;
+ }
+ }
+ }
+
+ public void setClientDiscoveryService(@Nullable EmbyClientDiscoveryService discovery) {
+ this.clientDiscoveryService = discovery;
+ }
+
+ @Nullable
+ public EmbyClientDiscoveryService getClientDiscoveryService() {
+ return clientDiscoveryService;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, pollCurrentValueBridge(channelUID));
+ } else {
+ logger.trace("Ignored command {} on {}", command, channelUID.getId());
+ }
+ }
+
+ public void updateDiscoveryStatus(String status) {
+ this.lastDiscoveryStatus = status;
+ updateState(new ChannelUID(getThing().getUID(), "discoveryStatus"), new StringType(status));
+ }
+
+ private EmbyBridgeConfiguration checkConfiguration() throws ConfigValidationException {
+ EmbyBridgeConfiguration embyConfig = getConfigAs(EmbyBridgeConfiguration.class);
+ if (embyConfig.api.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/thing.status.bridge.missingAPI");
+ throwValidationError("api", "@text/thing.status.bridge.missingAPI");
+ }
+ if (embyConfig.ipAddress.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing server address");
+ throwValidationError("ipAddress", "@text/thing.status.bridge.missingIP");
+ }
+ this.httputils = new EmbyHTTPUtils(30, embyConfig.api, embyConfig.ipAddress + ":" + embyConfig.port);
+ return embyConfig;
+ }
+
+ private void throwValidationError(String parameterName, String errorMessage) throws ConfigValidationException {
+ final TranslationProvider provider = requireNonNull(this.i18nProvider, "i18nProvider must not be null");
+ Bundle bundle = FrameworkUtil.getBundle(getClass());
+ ConfigValidationMessage message = new ConfigValidationMessage(parameterName, "error", errorMessage);
+ throw new ConfigValidationException(bundle, provider, Collections.singletonList(message));
+ }
+
+ private State pollCurrentValueBridge(ChannelUID channelUID) {
+ switch (channelUID.getId()) {
+ case "serverReachable":
+ return (getThing().getStatus() == ThingStatus.ONLINE) ? OnOffType.ON : OnOffType.OFF;
+ case "discoveryStatus":
+ return (this.lastDiscoveryStatus != null) ? new StringType(this.lastDiscoveryStatus) : UnDefType.UNDEF;
+ default:
+ return UnDefType.UNDEF;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ // cancel checker
+ final ScheduledFuture> future = this.connectionCheckerFuture;
+ if (future != null && !future.isCancelled()) {
+ future.cancel(true);
+ }
+ // close connection
+ final EmbyConnection conn = requireNonNull(this.connection, "connection not initialized");
+ conn.dispose();
+ // detach discovery
+ final EmbyClientDiscoveryService service = this.clientDiscoveryService;
+ if (service != null) {
+ service.clearBridge(this);
+ this.clientDiscoveryService = null;
+ }
+ super.dispose();
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java
new file mode 100644
index 00000000000..c84a704025a
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java
@@ -0,0 +1,542 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.handler;
+
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.emby.internal.EmbyBindingConstants.*;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatusDetail.*;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emby.internal.EmbyDeviceConfiguration;
+import org.openhab.binding.emby.internal.EmbyEventListener;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+import org.openhab.binding.emby.internal.util.EmbyThrottle;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.config.core.validation.ConfigValidationException;
+import org.openhab.core.config.core.validation.ConfigValidationMessage;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RewindFastforwardType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+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.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+/**
+ * The {@link EmbyDeviceHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+
+@NonNullByDefault
+public class EmbyDeviceHandler extends BaseThingHandler implements EmbyEventListener {
+
+ private final Logger logger = LoggerFactory.getLogger(EmbyDeviceHandler.class);
+
+ private @Nullable EmbyDeviceConfiguration config;
+ private @Nullable EmbyPlayStateModel currentPlayState;
+ private @Nullable EmbyBridgeHandler bridgeHandler;
+ private @Nullable String lastImageUrl;
+ private @Nullable String lastShowTitle;
+ private @Nullable String lastMediaType;
+ private boolean lastMuted = false;
+ private long lastCurrentTime = -1;
+ private long lastDuration = -1;
+
+ private final EmbyThrottle throttle = new EmbyThrottle(1000);
+
+ private TranslationProvider i18nProvider;
+
+ private static final List ALLOWED_IMAGE_TYPES = Collections.unmodifiableList(Arrays.asList("Primary", "Art",
+ "Backdrop", "Banner", "Logo", "Thumb", "Disc", "Box", "Screenshot", "Menu", "Chapter"));
+
+ public EmbyDeviceHandler(Thing thing, TranslationProvider i18nProvider) {
+ super(thing);
+ this.i18nProvider = i18nProvider;
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(EmbyActions.class);
+ }
+
+ public void sendGeneralCommand(String commandName) {
+ final EmbyBridgeHandler handler = bridgeHandler;
+ final EmbyPlayStateModel play = currentPlayState;
+ if (handler == null || play == null) {
+ throw new IllegalStateException("Cannot send command: no bridge or no active session");
+ }
+ String url = CONTROL_SESSION + play.getId() + CONTROL_GENERALCOMMAND + commandName;
+ handler.sendCommand(url);
+ }
+
+ public void sendGeneralCommandWithArgs(String commandName, String jsonArguments) {
+ final EmbyBridgeHandler handler = bridgeHandler;
+ final EmbyPlayStateModel play = currentPlayState;
+ if (handler == null || play == null) {
+ throw new IllegalStateException("Cannot send command: no bridge or no active session");
+ }
+ JsonObject args = JsonParser.parseString(jsonArguments).getAsJsonObject();
+ JsonObject envelope = new JsonObject();
+ envelope.add("Arguments", args);
+ String url = CONTROL_SESSION + play.getId() + CONTROL_GENERALCOMMAND + commandName;
+ handler.sendCommand(url, envelope.toString());
+ }
+
+ public void sendPlayWithParams(String itemIds, String playCommand, @Nullable Integer startPositionTicks,
+ @Nullable String mediaSourceId, @Nullable Integer audioStreamIndex, @Nullable Integer subtitleStreamIndex,
+ @Nullable Integer startIndex) {
+ final EmbyBridgeHandler handler = bridgeHandler;
+ final EmbyPlayStateModel play = currentPlayState;
+ if (handler == null || play == null) {
+ throw new IllegalStateException("No bridge or active session available");
+ }
+
+ JsonObject payload = new JsonObject();
+ payload.addProperty("ItemIds", itemIds);
+ payload.addProperty("PlayCommand", playCommand);
+ if (startPositionTicks != null) {
+ payload.addProperty("StartPositionTicks", startPositionTicks);
+ }
+ if (mediaSourceId != null) {
+ payload.addProperty("MediaSourceId", mediaSourceId);
+ }
+ if (audioStreamIndex != null) {
+ payload.addProperty("AudioStreamIndex", audioStreamIndex);
+ }
+ if (subtitleStreamIndex != null) {
+ payload.addProperty("SubtitleStreamIndex", subtitleStreamIndex);
+ }
+ if (startIndex != null) {
+ payload.addProperty("StartIndex", startIndex);
+ }
+
+ String url = CONTROL_SESSION + play.getId() + CONTROL_SENDPLAY;
+ handler.sendCommand(url, payload.toString());
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ updateState(channelUID, pollCurrentValue(channelUID));
+ return;
+ }
+
+ final Channel channel = thing.getChannel(channelUID.getId());
+ if (channel == null) {
+ logger.warn("Unsupported channel: {}", channelUID);
+ return;
+ }
+
+ final EmbyBridgeHandler handler = bridgeHandler;
+ final EmbyDeviceConfiguration cfg = config;
+ if (handler == null || cfg == null) {
+ return;
+ }
+
+ final EmbyPlayStateModel play = currentPlayState;
+ switch (channelUID.getId()) {
+ case CHANNEL_CONTROL:
+ if (play == null) {
+ updateState(channelUID, UnDefType.UNDEF);
+ return;
+ }
+ if (command instanceof PlayPauseType) {
+ String url = CONTROL_SESSION + play.getId()
+ + (PlayPauseType.PLAY.equals(command) ? CONTROL_PLAY : CONTROL_PAUSE);
+ handler.sendCommand(url);
+ }
+ break;
+
+ case CHANNEL_MUTE:
+ if (play == null) {
+ updateState(channelUID, UnDefType.UNDEF);
+ return;
+ }
+ String muteUrl = CONTROL_SESSION + play.getId()
+ + (OnOffType.ON.equals(command) ? CONTROL_MUTE : CONTROL_UNMUTE);
+ handler.sendCommand(muteUrl);
+ break;
+
+ case CHANNEL_STOP:
+ if (play == null) {
+ updateState(channelUID, UnDefType.UNDEF);
+ return;
+ }
+ if (OnOffType.ON.equals(command)) {
+ handler.sendCommand(CONTROL_SESSION + play.getId() + CONTROL_STOP);
+ }
+ break;
+
+ default:
+ logger.warn("Unsupported channel: {}", channelUID.getAsString());
+ break;
+ }
+ }
+
+ private State pollCurrentValue(ChannelUID channelUID) {
+ final EmbyPlayStateModel play = currentPlayState;
+
+ return switch (channelUID.getId()) {
+ case CHANNEL_CONTROL -> {
+ if (play == null) {
+ yield UnDefType.UNDEF;
+ }
+ Boolean paused = play.getEmbyPlayStatePausedState();
+ yield Boolean.TRUE.equals(paused) ? PlayPauseType.PAUSE : PlayPauseType.PLAY;
+ }
+ case CHANNEL_MUTE -> {
+ if (play == null) {
+ yield UnDefType.UNDEF;
+ }
+ Boolean muted = play.getEmbyMuteSate();
+ yield Boolean.TRUE.equals(muted) ? OnOffType.ON : OnOffType.OFF;
+ }
+ case CHANNEL_STOP -> {
+ if (play == null) {
+ yield UnDefType.UNDEF;
+ }
+ Boolean stopped = play.getEmbyPlayStatePausedState();
+ yield Boolean.TRUE.equals(stopped) ? OnOffType.ON : OnOffType.OFF;
+ }
+ case CHANNEL_TITLE -> createStringState(play != null ? play.getNowPlayingName() : null);
+ case CHANNEL_SHOWTITLE -> createStringState(lastShowTitle);
+ case CHANNEL_MEDIATYPE -> createStringState(lastMediaType);
+ case CHANNEL_CURRENTTIME -> (lastCurrentTime < 0) ? UnDefType.UNDEF
+ : createQuantityState(convertTicksToSeconds(lastCurrentTime), Units.SECOND);
+ case CHANNEL_DURATION -> (lastDuration < 0) ? UnDefType.UNDEF
+ : createQuantityState(convertTicksToSeconds(lastDuration), Units.SECOND);
+ case CHANNEL_IMAGEURL -> createStringState(lastImageUrl);
+ default -> UnDefType.UNDEF;
+ };
+ }
+
+ private void updateState(EmbyState state) {
+ updatePlayerState(state);
+
+ if (state == EmbyState.STOP || state == EmbyState.END) {
+ // reset all last-* fields
+ lastImageUrl = null;
+ lastShowTitle = null;
+ lastMediaType = null;
+ lastMuted = false;
+ lastCurrentTime = -1;
+ lastDuration = -1;
+
+ // restore original post-stop behavior
+ updatePlayerState(state); // sets CONTROL and STOP channels
+ updateState(CHANNEL_MUTE, OnOffType.from(false));
+
+ updateTitle("");
+ updateShowTitle("");
+ updatePrimaryImageURL("");
+ updateMediaType("");
+ updateCurrentTime(-1);
+ updateDuration(-1);
+ }
+ }
+
+ @Override
+ public void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport) {
+ final EmbyDeviceConfiguration cfg = config;
+ if (cfg == null || !playstate.compareDeviceId(cfg.deviceID)) {
+ return;
+ }
+ this.currentPlayState = playstate;
+
+ try {
+ URI imageURI = playstate.getPrimaryImageURL(hostname, embyport, cfg.imageImageType, cfg.imageMaxWidth,
+ cfg.imageMaxHeight);
+
+ if (playstate.getNowPlayingItem() == null) {
+ updateState(EmbyState.END);
+ updateState(EmbyState.STOP);
+ return;
+ }
+
+ if (playstate.getEmbyPlayStatePausedState()) {
+ logger.debug("Setting state to PAUSE for {}", playstate.getDeviceName());
+ updateState(EmbyState.PAUSE);
+ } else {
+ logger.debug("Setting state to PLAY for {}", playstate.getDeviceName());
+ updateState(EmbyState.PLAY);
+ }
+
+ // Image URL
+ String newImage = imageURI.toString();
+ if (!newImage.equals(lastImageUrl)) {
+ updatePrimaryImageURL(newImage);
+ logger.trace("Throttled updatePrimaryImageURL: {}", newImage);
+ lastImageUrl = newImage;
+ }
+
+ // Mute (instant)
+ boolean newMute = playstate.getEmbyMuteSate();
+ if (newMute != lastMuted) {
+ updateState(CHANNEL_MUTE, OnOffType.from(newMute));
+ logger.trace("updateMuted: {}", newMute);
+ lastMuted = newMute;
+ }
+
+ // Show Title
+ String newTitle = playstate.getNowPlayingName();
+ if (!newTitle.equals(lastShowTitle)) {
+ updateShowTitle(newTitle);
+ logger.trace("Throttled updateShowTitle: {}", newTitle);
+
+ lastShowTitle = newTitle;
+ }
+
+ // CurrentTime
+ long newTime = playstate.getNowPlayingTime().longValue();
+ if (newTime != lastCurrentTime) {
+ updateCurrentTime(newTime);
+ logger.trace("Throttled updateCurrentTime: {}", newTime);
+
+ lastCurrentTime = newTime;
+ }
+
+ // Duration
+ long newDur = playstate.getNowPlayingTotalTime().longValue();
+ if (newDur != lastDuration) {
+ updateDuration(newDur);
+ logger.trace("Throttled updateDuration: {}", newDur);
+
+ lastDuration = newDur;
+ }
+
+ // MediaType
+ String newType = playstate.getNowPlayingMediaType();
+ if (!newType.equals(lastMediaType)) {
+ updateMediaType(newType);
+ logger.trace("Throttled updateMediaType: {}", newType);
+ lastMediaType = newType;
+ }
+
+ } catch (URISyntaxException e) {
+ logger.debug("Unable to create image URL for {}: {}", playstate.getDeviceName(), e.getMessage());
+ }
+ }
+
+ private EmbyDeviceConfiguration validateConfiguration() throws ConfigValidationException {
+ Object deviceId = requireNonNull(thing.getConfiguration().get(CONFIG_DEVICE_ID));
+ if (deviceId.toString().isEmpty()) {
+ throwValidationError(CONFIG_DEVICE_ID, "@text/thing.status.device.config.noDeviceID");
+ }
+ EmbyDeviceConfiguration cfg = new EmbyDeviceConfiguration(deviceId.toString());
+
+ final Channel imgChannel = thing.getChannel(CHANNEL_IMAGEURL);
+ if (imgChannel == null) {
+ throwValidationError(CHANNEL_IMAGEURL,
+ "@text/thing.status.device.config.noChannelDefined: " + CHANNEL_IMAGEURL);
+ } else {
+ Configuration imgCfg = imgChannel.getConfiguration();
+ String maxWidth = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_MAXWIDTH, "");
+ if (!maxWidth.matches("\\d*")) {
+ throwValidationError(CHANNEL_IMAGEURL_CONFIG_MAXWIDTH,
+ "@text/thing.status.device.config.notNumber" + CHANNEL_IMAGEURL_CONFIG_MAXWIDTH);
+ } else {
+ cfg.imageMaxWidth = maxWidth;
+ }
+ String maxHeight = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT, "");
+ if (!maxHeight.matches("\\d*")) {
+ throwValidationError(CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT,
+ "@text/thing.status.device.config.notNumber" + CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT);
+ }
+ cfg.imageMaxHeight = maxHeight;
+ String pct = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED, "false");
+ if (!("true".equalsIgnoreCase(pct) || "false".equalsIgnoreCase(pct))) {
+ throwValidationError(CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED,
+ "thing.status.device.config.booleanRequried" + CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED);
+ }
+ cfg.imagePercentPlayed = Boolean.parseBoolean(pct);
+
+ String imgType = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_TYPE, "Primary");
+ if (!ALLOWED_IMAGE_TYPES.contains(imgType)) {
+ throwValidationError(CHANNEL_IMAGEURL_CONFIG_TYPE,
+ "@text/thing.status.device.config.invalidImageType" + imgType);
+ }
+ cfg.imageImageType = imgType;
+ }
+ return cfg;
+ }
+
+ private void throwValidationError(String parameterName, String errorMessage) throws ConfigValidationException {
+ TranslationProvider provider = Objects.requireNonNull(i18nProvider,
+ "TranslationProvider must not be null for validation");
+ Bundle bundle = FrameworkUtil.getBundle(getClass());
+ ConfigValidationMessage msg = new ConfigValidationMessage(parameterName, "error", errorMessage);
+ throw new ConfigValidationException(bundle, provider, Collections.singletonList(msg));
+ }
+
+ private String getOrDefault(Configuration config, String key, String defaultValue) {
+ String val = (String) config.get(key);
+ return val != null ? val : defaultValue;
+ }
+
+ @Override
+ public void initialize() {
+ updateStatus(ThingStatus.UNKNOWN);
+ scheduler.execute(() -> {
+ try {
+ this.config = validateConfiguration();
+
+ if (!(getBridge() instanceof Bridge bridge)
+ || !(bridge.getHandler() instanceof EmbyBridgeHandler bridgeHandler)) {
+ updateStatus(ThingStatus.OFFLINE, CONFIGURATION_ERROR, "@text/thing.status.device.noBridge");
+ return;
+ }
+ this.bridgeHandler = bridgeHandler;
+
+ if (bridge.getStatus() == OFFLINE) {
+ updateStatus(OFFLINE, BRIDGE_OFFLINE, "@text/thing.status.device.bridgeOffline");
+ return;
+ }
+
+ updateStatus(ThingStatus.ONLINE);
+ } catch (ConfigValidationException e) {
+ updateStatus(ThingStatus.OFFLINE, CONFIGURATION_ERROR,
+ "@text/thing.status.device.configInValid " + e.getMessage());
+ } catch (Exception e) {
+ updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, "@text/thing.status.device.initalizationFalied");
+ logger.error("Initialization failed: {}", e.getMessage());
+ }
+ });
+ }
+
+ @Override
+ public void updateConnectionState(boolean connected) {
+ if (connected) {
+ updateStatus(ThingStatus.ONLINE);
+ } else {
+ updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR);
+ }
+ }
+
+ @Override
+ public void updateScreenSaverState(boolean screenSaveActive) {
+ /* no-op */ }
+
+ @Override
+ public void updatePlayerState(EmbyState state) {
+ switch (state) {
+ case PLAY:
+ updateState(CHANNEL_CONTROL, PlayPauseType.PLAY);
+ updateState(CHANNEL_STOP, OnOffType.OFF);
+ break;
+ case PAUSE:
+ updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
+ updateState(CHANNEL_STOP, OnOffType.OFF);
+ break;
+ case STOP:
+ case END:
+ updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE);
+ updateState(CHANNEL_STOP, OnOffType.ON);
+ break;
+ case FASTFORWARD:
+ updateState(CHANNEL_CONTROL, RewindFastforwardType.FASTFORWARD);
+ updateState(CHANNEL_STOP, OnOffType.OFF);
+ break;
+ case REWIND:
+ updateState(CHANNEL_CONTROL, RewindFastforwardType.REWIND);
+ updateState(CHANNEL_STOP, OnOffType.OFF);
+ break;
+ }
+ }
+
+ @Override
+ public void updateTitle(String title) {
+ if (throttle.shouldProceed("updateTitle")) {
+ updateState(CHANNEL_TITLE, createStringState(title));
+ }
+ }
+
+ @Override
+ public void updatePrimaryImageURL(String imageUrl) {
+ if (throttle.shouldProceed("updatePrimaryImageURL")) {
+ updateState(CHANNEL_IMAGEURL, createStringState(imageUrl));
+ }
+ }
+
+ @Override
+ public void updateShowTitle(String title) {
+ if (throttle.shouldProceed("updateShowTitle")) {
+ updateState(CHANNEL_SHOWTITLE, createStringState(title));
+ }
+ }
+
+ @Override
+ public void updateMediaType(String mediaType) {
+ if (throttle.shouldProceed("updateMediaType")) {
+ updateState(CHANNEL_MEDIATYPE, createStringState(mediaType));
+ }
+ }
+
+ @Override
+ public void updateCurrentTime(long currentTime) {
+ if (throttle.shouldProceed("updateCurrentTime")) {
+ updateState(CHANNEL_CURRENTTIME, createQuantityState(convertTicksToSeconds(currentTime), Units.SECOND));
+ }
+ }
+
+ @Override
+ public void updateDuration(long duration) {
+ if (throttle.shouldProceed("updateDuration")) {
+ updateState(CHANNEL_DURATION, createQuantityState(convertTicksToSeconds(duration), Units.SECOND));
+ }
+ }
+
+ private State createStringState(@Nullable String string) {
+ return (string == null || string.isBlank()) ? UnDefType.UNDEF : new StringType(string);
+ }
+
+ private State createQuantityState(@Nullable Number value, Unit> unit) {
+ return (value == null) ? UnDefType.UNDEF : new QuantityType<>(value, unit);
+ }
+
+ public static double convertTicksToSeconds(long ticks) {
+ double raw = ticks / 10_000_000.0;
+ return Math.round(raw * 10.0) / 10.0;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java
new file mode 100644
index 00000000000..7b1ba362204
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.model;
+
+import java.math.BigDecimal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * embyPlayState - part of the model for the json object received from the server
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class EmbyNowPlayingCurrentProgram {
+
+ @SerializedName("RunTimeTicks")
+ private BigDecimal runTimeTicks = BigDecimal.ZERO;
+
+ /**
+ * @return the run time of the item
+ */
+ BigDecimal getRunTimeTicks() {
+ return runTimeTicks;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java
new file mode 100644
index 00000000000..ba7ba9d9128
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.model;
+
+import static java.util.Objects.requireNonNull;
+
+import java.math.BigDecimal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * embyPlayState - part of the model for the json object received from the server
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class EmbyNowPlayingItem {
+
+ @SerializedName("Name")
+ private String name = "";
+ @SerializedName("OriginalTitle")
+ private String originalTitle = "";
+ @SerializedName("Id")
+ private String id = "";
+ @SerializedName("RunTimeTicks")
+ private BigDecimal runTimeTicks = BigDecimal.ZERO;
+ @SerializedName("Overview")
+ private String overview = "";
+ @SerializedName("SeasonId")
+ private String seasonId = "";
+ @SerializedName("Type")
+ private String nowPlayingType = "";
+ @SerializedName("CurrentProgram")
+ private @Nullable EmbyNowPlayingCurrentProgram currentProgram;
+ private final Logger logger = LoggerFactory.getLogger(EmbyNowPlayingItem.class);
+
+ String getName() {
+ return name;
+ }
+
+ String getSeasonId() {
+ return this.seasonId;
+ }
+
+ String getNowPlayingType() {
+ return nowPlayingType;
+ }
+
+ String getOriginalTitle() {
+ if (originalTitle.isEmpty()) {
+ return name;
+ } else {
+ return originalTitle;
+ }
+ }
+
+ /**
+ * @return the media source id of the now playing item
+ */
+ String getId() {
+ return this.id;
+ }
+
+ /**
+ * @return the total runtime ticks of the currently playing item, returns a BigDecimal 0 if this value is not set or
+ * Unknown
+ */
+ BigDecimal getRunTimeTicks() {
+ logger.debug("the media type is {}", nowPlayingType);
+ BigDecimal returnValue = new BigDecimal(0);
+ switch (nowPlayingType) {
+ case "TvChannel":
+ returnValue = requireNonNull(currentProgram, "currentProgram must not be null").getRunTimeTicks();
+ break;
+ case "Recording":
+ break;
+ case "Episode":
+ case "Movie":
+ returnValue = runTimeTicks;
+ break;
+ }
+ return returnValue;
+ }
+
+ String getOverview() {
+ return overview;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java
new file mode 100644
index 00000000000..eb1485a433c
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.model;
+
+import java.math.BigDecimal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * embyPlayState - part of the model for the json object received from the server
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class EmbyPlayState {
+
+ @SerializedName("PositionTicks")
+ private BigDecimal positionTicks = BigDecimal.ZERO;
+
+ @SerializedName("CanSeek")
+ private boolean canSeek = false;
+
+ @SerializedName("IsPaused")
+ private boolean isPaused = false;
+
+ @SerializedName("IsMuted")
+ private boolean isMuted = false;
+
+ @SerializedName("VolumeLevel")
+ private Integer volumeLevel = 0;
+
+ @SerializedName("MediaSourceId")
+ private String mediaSoureId = "";
+
+ @SerializedName("PlayMethod")
+ private String playMethod = "";
+
+ @SerializedName("repeatMode")
+ private String repeatMode = "";
+
+ /**
+ * @return the current position in the playback of the now playing item, can be compared to the total
+ * runtimeticks
+ * to get percentage played
+ */
+ BigDecimal getPositionTicks() {
+ return positionTicks;
+ }
+
+ boolean getPaused() {
+ return isPaused;
+ }
+
+ boolean getIsMuted() {
+ return isMuted;
+ }
+
+ /**
+ * @return the item id of the now playing item
+ */
+ String getMediaSourceID() {
+ return mediaSoureId;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java
new file mode 100644
index 00000000000..ebb3831d804
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.model;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EmbyPlayStateModel} holds data about the current play state
+ * and provides safe getters that guard against missing playState or nowPlayingItem.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyPlayStateModel {
+
+ @Nullable
+ @com.google.gson.annotations.SerializedName("PlayState")
+ private EmbyPlayState playState;
+
+ @com.google.gson.annotations.SerializedName("RemoteEndPoint")
+ private String remoteEndPoint = "";
+
+ @com.google.gson.annotations.SerializedName("Id")
+ private String id = "";
+
+ @com.google.gson.annotations.SerializedName("UserId")
+ private String userId = "";
+
+ @com.google.gson.annotations.SerializedName("UserName")
+ private String userName = "";
+
+ @com.google.gson.annotations.SerializedName("Client")
+ private String client = "";
+
+ @com.google.gson.annotations.SerializedName("DeviceName")
+ private String deviceName = "";
+
+ @com.google.gson.annotations.SerializedName("DeviceId")
+ private String deviceId = "";
+
+ @com.google.gson.annotations.SerializedName("SupportsRemoteControl")
+ private Boolean supportsRemoteControl = false;
+
+ @Nullable
+ @com.google.gson.annotations.SerializedName("NowPlayingItem")
+ private EmbyNowPlayingItem nowPlayingItem;
+
+ private final Logger logger = LoggerFactory.getLogger(EmbyPlayStateModel.class);
+
+ /** May be null if nothing is playing. */
+ public @Nullable EmbyPlayState getPlayStates() {
+ return playState;
+ }
+
+ public Boolean getEmbyPlayStatePausedState() {
+ final EmbyPlayState state = this.playState;
+ return (state != null) ? state.getPaused() : Boolean.FALSE;
+ }
+
+ public Boolean getEmbyMuteSate() {
+ final EmbyPlayState state = this.playState;
+ return (state != null) ? state.getIsMuted() : Boolean.FALSE;
+ }
+
+ public String getNowPlayingName() {
+ final EmbyNowPlayingItem item = this.nowPlayingItem;
+ return (item != null) ? item.getName() : "";
+ }
+
+ public BigDecimal getNowPlayingTime() {
+ final EmbyPlayState state = this.playState;
+ return (state != null) ? state.getPositionTicks() : BigDecimal.ZERO;
+ }
+
+ public BigDecimal getNowPlayingTotalTime() {
+ final EmbyNowPlayingItem item = this.nowPlayingItem;
+ return (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO;
+ }
+
+ public String getNowPlayingMediaType() {
+ final EmbyNowPlayingItem item = this.nowPlayingItem;
+ return (item != null) ? item.getNowPlayingType() : "";
+ }
+
+ public Boolean compareDeviceId(String compareId) {
+ // deviceId is never null; equals() handles null compareId safely
+ return getDeviceId().equals(compareId);
+ }
+
+ /**
+ * Returns the fraction (0–1) of playback completed, rounded to 2 decimal places.
+ */
+ public BigDecimal getPercentPlayed() {
+ final EmbyPlayState state = this.playState;
+ final EmbyNowPlayingItem item = this.nowPlayingItem;
+
+ BigDecimal positionTicks = (state != null) ? state.getPositionTicks() : BigDecimal.ZERO;
+ BigDecimal runTimeTicks = (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO;
+
+ logger.debug("The play state position is {}", positionTicks);
+
+ if (BigDecimal.ZERO.equals(runTimeTicks)) {
+ return BigDecimal.ZERO;
+ }
+ return positionTicks.divide(runTimeTicks, 2, RoundingMode.HALF_UP);
+ }
+
+ public @Nullable EmbyNowPlayingItem getNowPlayingItem() {
+ return nowPlayingItem;
+ }
+
+ /** @return the IP address of the user playing the media */
+ public String getRemoteEndPoint() {
+ return remoteEndPoint;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ /** @return the Emby ID of the user */
+ public String getuserId() {
+ return userId;
+ }
+
+ public String userName() {
+ return userName;
+ }
+
+ public String getClient() {
+ return client;
+ }
+
+ public String getDeviceName() {
+ return deviceName;
+ }
+
+ public String getDeviceId() {
+ EmbyDeviceEncoder encoder = new EmbyDeviceEncoder();
+ return encoder.encodeDeviceID(deviceId);
+ }
+
+ public Boolean getSupportsRemoteControl() {
+ return supportsRemoteControl;
+ }
+
+ public URI getPrimaryImageURL(String embyHost, int embyPort, String embyType, String maxWidth, String maxHeight)
+ throws URISyntaxException {
+ logger.debug("Received an image URL request for: {} , port: {}, type: {}, max: {}/{} , percentPlayed: {}",
+ embyHost, embyPort, embyType, maxWidth, maxHeight, getPercentPlayed());
+
+ final EmbyNowPlayingItem item = this.nowPlayingItem;
+ if (item == null) {
+ // no media playing → special URI
+ return new URI("http", null, "NotPlaying", 8096, null, null, null);
+ }
+
+ // build path based on media type
+ String imagePath = item.getNowPlayingType().equalsIgnoreCase("Episode")
+ ? "/emby/items/" + item.getSeasonId() + "/Images/" + embyType
+ : "/emby/items/" + item.getId() + "/Images/" + embyType;
+
+ // build query string
+ StringBuilder query = new StringBuilder();
+
+ query.append("MaxWidth=").append(maxWidth).append("&");
+
+ query.append("MaxHeight=").append(maxHeight).append("&");
+
+ return new URI("http", null, embyHost, embyPort, imagePath, query.toString(), null);
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java
new file mode 100644
index 00000000000..b3c4d177996
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * embyPlayState - part of the model for the json object received from the server
+ *
+ * @author Zachary Christiansen - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class EmbyPlayingPostJsonModel {
+
+ @SerializedName("ItemsIds")
+ private String itemsIds = "";
+
+ @SerializedName("PlayCommand")
+ private String playCommand = "";
+
+ @SerializedName("StartPositionTicks")
+ private String startPositionTicks = "";
+
+ @SerializedName("MediaSourceId")
+ private String mediaSourceId = "";
+
+ @SerializedName("AudioStreamIndex")
+ private String audioStreamIndex = "";
+
+ @SerializedName("SubtitleStreamIndex")
+ private String subtitleStreamIndex = "";
+
+ @SerializedName("StartIndex")
+ private String startIndex = "";
+
+ public String getItemsIds() {
+ return this.itemsIds;
+ }
+
+ public void setItemsIds(String settingItemsIds) {
+ this.itemsIds = settingItemsIds;
+ }
+
+ public String getPlayCommand() {
+ return this.playCommand;
+ }
+
+ public void setPlayCommand(String settingPlayCommand) {
+ this.playCommand = settingPlayCommand;
+ }
+
+ public String getStartPositionTicks() {
+ return this.startPositionTicks;
+ }
+
+ public void setStartPositionTicks(String settingStartPositionTicks) {
+ this.startPositionTicks = settingStartPositionTicks;
+ }
+
+ public String getMediaSourceIds() {
+ return this.mediaSourceId;
+ }
+
+ public void setMediaSourceId0(String settingMediaSourceId) {
+ this.mediaSourceId = settingMediaSourceId;
+ }
+
+ public String getSubtitleStreamIndex() {
+ return this.subtitleStreamIndex;
+ }
+
+ public void setSubtitleStreamIndex(String settingSubtitleStreamIndex) {
+ this.subtitleStreamIndex = settingSubtitleStreamIndex;
+ }
+
+ public String getStartIndex() {
+ return this.startIndex;
+ }
+
+ public void setStartIndex(String settingStartIndex) {
+ this.startIndex = settingStartIndex;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java
new file mode 100644
index 00000000000..4b8d5a5c96c
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * EmbyClientSocket implements the low level communication to Emby through
+ * websocket. Usually this communication is done through port 9090
+ *
+ * @author Zachary Christiansen - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class EmbyClientSocket {
+
+ private static final int REQUEST_TIMEOUT_MS = 10000;
+ private final Logger logger = LoggerFactory.getLogger(EmbyClientSocket.class);
+ private final ScheduledExecutorService scheduler;
+ private final Gson mapper = new Gson();
+ private final EmbyClientSocketEventListener eventHandler;
+ private final WebSocketClient client;
+ private final ClientUpgradeRequest request = new ClientUpgradeRequest();
+ private final EmbyWebSocketListener socket = new EmbyWebSocketListener();
+
+ private @Nullable URI uri;
+ private @Nullable Session session;
+
+ private volatile boolean shouldReconnect = true;
+ private int reconnectAttempts = 0;
+
+ public EmbyClientSocket(EmbyClientSocketEventListener handler, @Nullable URI setUri,
+ ScheduledExecutorService setScheduler, WebSocketClient sharedWebSocketClient) {
+ this.eventHandler = handler;
+ this.uri = setUri;
+ this.scheduler = setScheduler;
+ this.client = sharedWebSocketClient;
+ }
+
+ public synchronized void open() throws Exception {
+ if (isConnected()) {
+ logger.debug("already open");
+ return;
+ }
+
+ Future future = requireNonNull(client.connect(socket, uri, request),
+ "WebSocketClient.connect returned null Future");
+ Session wsSession = requireNonNull(future.get(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "Future.get returned null Session");
+ requireNonNull(wsSession, "WebSocketClient.connect returned null Session");
+ this.session = wsSession;
+ logger.debug("Connected to Emby");
+ // Reset retry counters
+ reconnectAttempts = 0;
+ // Fire the connection‐opened event
+ scheduler.submit(() -> eventHandler.onConnectionOpened());
+ }
+
+ public void close() {
+ shouldReconnect = false;
+ reconnectAttempts = 0;
+ Session s = session;
+ if (s != null) {
+ try {
+ s.close();
+ } catch (Exception e) {
+ logger.debug("Exception during closing the websocket: {}", e.getMessage(), e);
+ }
+ session = null;
+ }
+ }
+
+ public boolean isConnected() {
+ Session s = session;
+ return s != null && s.isOpen();
+ }
+
+ public synchronized void attemptReconnect() {
+ if (!shouldReconnect) {
+ return;
+ }
+ reconnectAttempts++;
+ long delay = Math.min(60_000, (1 << Math.min(reconnectAttempts, 6)) * 1000L);
+ logger.debug("Scheduling reconnect #{} in {}ms", reconnectAttempts, delay);
+ scheduler.schedule(() -> {
+ try {
+ open();
+ } catch (Exception e) {
+ logger.debug("Reconnect attempt #{} failed: {}", reconnectAttempts, e.getMessage());
+ attemptReconnect();
+ }
+ }, delay, TimeUnit.MILLISECONDS);
+ }
+
+ public void sendCommand(String methodName, String dataParams) {
+ JsonObject payload = new JsonObject();
+ payload.addProperty("MessageType", methodName);
+ payload.addProperty("Data", dataParams);
+ scheduler.submit(() -> {
+ try {
+ Session s = this.session;
+ if (s == null || !s.isOpen()) {
+ logger.debug("Cannot send {}, session not open", methodName);
+ return;
+ }
+ s.getRemote().sendString(mapper.toJson(payload));
+ logger.debug("Sent command: {}", payload);
+ } catch (IOException e) {
+ logger.error("Failed sending {}: {}", methodName, e.getMessage(), e);
+ }
+ });
+ }
+
+ @WebSocket
+ public class EmbyWebSocketListener {
+ @OnWebSocketConnect
+ public void onConnect(Session wssession) {
+ }
+
+ @OnWebSocketMessage
+ public void onMessage(String message) {
+ logger.trace("Message received from server: {}", message);
+ JsonObject json = JsonParser.parseString(message).getAsJsonObject();
+ if (json.has("Data")) {
+ JsonArray dataArr = json.get("Data").getAsJsonArray();
+ Type listType = new TypeToken>() {
+ }.getType();
+ @SuppressWarnings("unchecked")
+ List states = (List) mapper.fromJson(dataArr, listType);
+
+ if (states != null) {
+ states.forEach(eventHandler::handleEvent);
+ } else {
+ logger.trace("Parsed EmbyPlayStateModel list was null; skipping event handling");
+ }
+ }
+ }
+
+ @OnWebSocketClose
+ public void onClose(int statusCode, @Nullable String reason) {
+ logger.debug("WebSocket closed ({}): {}", statusCode, reason);
+ session = null;
+ eventHandler.onConnectionClosed();
+ if (shouldReconnect) {
+ attemptReconnect();
+ }
+ }
+
+ @OnWebSocketError
+ public void onError(@Nullable Throwable error) {
+ logger.error("WebSocket error, scheduling reconnect", error);
+ Session current = session;
+ if (current != null) {
+ try {
+ current.disconnect();
+ } catch (Exception e) {
+ logger.error("Failed to cleanly disconnect WebSocket session: {}", e.getMessage(), e);
+ }
+ current = null;
+ }
+ eventHandler.onConnectionClosed();
+ if (shouldReconnect) {
+ attemptReconnect();
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java
new file mode 100644
index 00000000000..d388250af11
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+
+/**
+ * Listener interface for receiving events from the {@code EmbyClientSocket}.
+ * Implement this interface to handle connection lifecycle events and playback state updates
+ * received from an Emby server.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public interface EmbyClientSocketEventListener {
+
+ /**
+ * Called when a playback state update is received from the Emby server.
+ * Implementers should handle the new {@link EmbyPlayStateModel} accordingly.
+ *
+ * @param playstate the updated playback state information
+ */
+ void handleEvent(EmbyPlayStateModel playstate);
+
+ /**
+ * Called when the connection to the Emby server has been successfully opened.
+ * Implementers can perform initialization or resource allocation here.
+ */
+ void onConnectionOpened();
+
+ /**
+ * Called when the connection to the Emby server has been closed.
+ * Implementers should handle cleanup or reconnection logic here.
+ */
+ void onConnectionClosed();
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java
new file mode 100644
index 00000000000..6e87ac27a69
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.openhab.binding.emby.internal.EmbyBridgeListener;
+import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * EmbyConnection provides an API for accessing an Emby device.
+ *
+ * All nullable fields are checked via a local copy + null-guard before use,
+ * per openHAB’s Null Annotations guidelines.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyConnection implements EmbyClientSocketEventListener, AutoCloseable {
+
+ private final Logger logger = LoggerFactory.getLogger(EmbyConnection.class);
+
+ private String hostname = "";
+ private int embyport = 0;
+
+ private @Nullable URI wsUri;
+ private @Nullable EmbyClientSocket socket;
+ private @Nullable ScheduledExecutorService schedulerInstance;
+
+ private int refreshRate = 0;
+
+ private final EmbyBridgeListener listener;
+ private final WebSocketClient sharedWebSocketClient;
+
+ public EmbyConnection(EmbyBridgeListener listener, WebSocketClient embyWebSocketClient) {
+ this.listener = listener;
+ this.sharedWebSocketClient = embyWebSocketClient;
+ }
+
+ @Override
+ public synchronized void onConnectionClosed() {
+ listener.updateConnectionState(false);
+ }
+
+ @Override
+ public synchronized void onConnectionOpened() {
+ listener.updateConnectionState(true);
+
+ // local copy + guard for socket:
+ final EmbyClientSocket sock = this.socket;
+ // once the connection is open, start sessions (if connected)
+ if (sock != null && sock.isConnected()) {
+ sock.sendCommand("SessionsStart", "0," + refreshRate);
+ } else {
+ logger.warn("onConnectionOpened() called but socket is not yet connected");
+ }
+ }
+
+ public synchronized void connect(String setHostName, int port, String apiKey, ScheduledExecutorService scheduler,
+ int refreshRate) {
+ this.schedulerInstance = scheduler;
+ this.hostname = setHostName;
+ this.embyport = port;
+ this.refreshRate = refreshRate;
+
+ try {
+ close(); // tear down any previous socket
+
+ // build and store the WS URI
+ wsUri = new URI("ws", null, hostname, embyport, null, "api_key=" + apiKey, null);
+
+ // create and start the socket
+ EmbyClientSocket localSocket = requireNonNull(
+ new EmbyClientSocket(this, wsUri, scheduler, sharedWebSocketClient),
+ "EmbyClientSocket constructor returned null");
+ localSocket.attemptReconnect();
+ this.socket = localSocket;
+ } catch (URISyntaxException e) {
+ logger.error("Exception constructing URI host={}, port={}", hostname, embyport, e);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ final EmbyClientSocket sock = this.socket;
+ if (sock != null) {
+ sock.close(); // stops reconnection internally
+ this.socket = null;
+ }
+ }
+
+ /**
+ * Cleans up all resources held by this connection:
+ * - closes the WebSocket socket (stopping any reconnection loops)
+ * - shuts down the scheduler if present
+ */
+ public synchronized void dispose() {
+ // close the socket
+ close();
+ ScheduledExecutorService localScheduler = schedulerInstance;
+ // shut down the scheduler
+ if (localScheduler != null) {
+ try {
+ localScheduler.shutdownNow();
+ if (!localScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+ logger.warn("Scheduler did not terminate cleanly");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ logger.warn("Interrupted while shutting down scheduler", e);
+ }
+ schedulerInstance = null;
+ }
+
+ listener.updateConnectionState(false);
+ logger.debug("Disposed EmbyConnection for {}:{}", hostname, embyport);
+ }
+
+ @Override
+ public void handleEvent(EmbyPlayStateModel playstate) {
+ logger.debug("Received event from EMBY server, passing to bridge: host={}, port={}", hostname, embyport);
+ listener.handleEvent(playstate, hostname, embyport);
+ }
+
+ public boolean checkConnection() {
+ final EmbyClientSocket sock = this.socket;
+ if (!(sock instanceof EmbyClientSocket) || !sock.isConnected()) {
+ logger.debug("Connection down, scheduling reconnect to {}", wsUri);
+ if (sock instanceof EmbyClientSocket) {
+ sock.attemptReconnect();
+ }
+ return false;
+ }
+ return true;
+ }
+
+ public String getConnectionName() {
+ // blow up early if no URI
+ final URI uri = requireNonNull(wsUri, "EmbyConnection not initialized (call connect() first)");
+ return uri.toString();
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java
new file mode 100644
index 00000000000..27ed393b8ec
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EmbyDeviceEncoder} is responsible for encoding device identifiers
+ * for communication with the Emby server. It transforms a given device ID into
+ * a format acceptable by the Emby protocol by replacing non-alphanumeric
+ * characters with a fixed token.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyDeviceEncoder {
+
+ /**
+ * Encodes the specified device identifier by replacing any character
+ * that is not an ASCII letter (A-Z, a-z) or digit (0-9) with the token
+ * {@code UYHJKU}. This ensures the device ID conforms to the Emby protocol
+ * requirements.
+ *
+ * @param deviceID the original device identifier to encode
+ * @return the encoded device identifier, where all non-alphanumeric characters
+ * have been replaced with {@code UYHJKU}
+ */
+ public String encodeDeviceID(String deviceID) {
+ return deviceID.replaceAll("[^A-Za-z0-9]", "UYHJKU");
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java
new file mode 100644
index 00000000000..dddf2fb6021
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HTTP Get and Put request class.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyHTTPUtils {
+ private final Logger logger = LoggerFactory.getLogger(EmbyHTTPUtils.class);
+ private int requestTimeout;
+ private String apiKey;
+ private String hostIpPort;
+ private String logTest = "";
+
+ public EmbyHTTPUtils(int requestTimeout, String apiKey, String hostIpPort) {
+ this.requestTimeout = (int) TimeUnit.SECONDS.toMillis(requestTimeout);
+ this.apiKey = apiKey;
+ this.hostIpPort = hostIpPort;
+ }
+
+ public void setRequestTimeout(int requestTimeout) {
+ this.requestTimeout = (int) TimeUnit.SECONDS.toMillis(requestTimeout);
+ }
+
+ /**
+ * Sends an HTTP POST request to the Emby server.
+ *
+ * @param urlAddress the endpoint path (appended to the configured hostIpPort) to send the request to
+ * @param payload the JSON payload to include in the request body
+ * @return the response body as a string
+ * @throws IOException if an I/O error occurs during the request
+ */
+ private String doPost(String urlAddress, String payload) throws IOException {
+ urlAddress = "http://" + hostIpPort + urlAddress;
+ logger.debug("The String url we want to post is : {}", urlAddress);
+ logger.debug("The payload we want to post is: {}", payload);
+ ByteArrayInputStream input = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
+ logTest = HttpUtil.executeUrl("POST", urlAddress, getHttpHeaders(), input, "application/json", requestTimeout);
+ logger.debug("{}", logTest);
+ return logTest;
+ }
+
+ protected Properties getHttpHeaders() {
+ Properties httpHeaders = new Properties();
+ httpHeaders.put("Content-Type", "application/json");
+ httpHeaders.put("X-Emby-Token", this.apiKey);
+ return httpHeaders;
+ }
+
+ /**
+ * Sends an HTTP POST request to the Emby server with retry logic.
+ *
+ * Implements a simple linear backoff strategy between retries.
+ *
+ * @param urlAddress the endpoint path (appended to the configured hostIpPort) to send the request to
+ * @param payload the JSON payload to include in the request body
+ * @param retryCount the maximum number of retry attempts before failing
+ * @return the response body as a string, or null if no response was received
+ * @throws EmbyHttpRetryExceeded if the number of retries is exceeded or the thread is interrupted
+ */
+ public synchronized @Nullable String doPost(String urlAddress, String payload, int retryCount)
+ throws EmbyHttpRetryExceeded {
+ String response = null;
+ int x = 0;
+
+ while (true) {
+ try {
+ response = doPost(urlAddress, payload);
+ break;
+ } catch (IOException e) {
+ x++;
+ try {
+ Thread.sleep(1000 * x); // Simple linear backoff
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt(); // preserve interrupt status
+ logger.warn("Sleep interrupted during retry delay", ie);
+ throw new EmbyHttpRetryExceeded("Interrupted while retrying POST request", ie);
+ }
+
+ if (x > retryCount) {
+ logger.warn("Attempt {} failed to POST to {}: {}", x, urlAddress, e.getMessage());
+ throw new EmbyHttpRetryExceeded("The number of retry attempts was exceeded", e.getCause());
+ }
+ }
+ }
+ return response;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java
new file mode 100644
index 00000000000..f966014e634
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.protocol;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Custom exception class to be thrown when number of retries is exceeded.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyHttpRetryExceeded extends Exception {
+
+ // Properly handle serialization warning instead of suppressing it
+ private static final long serialVersionUID = 1L;
+
+ public EmbyHttpRetryExceeded(String message, @Nullable Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java
new file mode 100644
index 00000000000..daa224ef885
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2010-2025 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.emby.internal.util;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EmbyThrottle} is a Utility to throttle high-frequency events on a per-key basis.
+ * Ensures updates for each key are only allowed after a configured interval.
+ *
+ * @author Zachary Christiansen - Initial contribution
+ */
+@NonNullByDefault
+public class EmbyThrottle {
+
+ private final long intervalMillis;
+ private final Map lastExecutionTimes = new ConcurrentHashMap<>();
+
+ public EmbyThrottle(long intervalMillis) {
+ this.intervalMillis = intervalMillis;
+ }
+
+ /**
+ * Returns true if the operation associated with the given key should proceed,
+ * based on the configured throttle interval.
+ *
+ * @param key A unique identifier for the event type (e.g. "updateTitle").
+ * @return true if enough time has passed since the last execution for this key.
+ */
+ public boolean shouldProceed(String key) {
+ long now = System.currentTimeMillis();
+ return lastExecutionTimes.compute(key, (k, lastTime) -> {
+ if (lastTime == null || now - lastTime >= intervalMillis) {
+ return now; // Allow update and set new timestamp
+ } else {
+ return lastTime; // Block update
+ }
+ }) == now;
+ }
+}
diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 00000000000..a688774ffce
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,60 @@
+
+
+
+ binding
+ Emby Binding
+
+ The Emby Binding integrates Emby, a personal media server (https://emby.media/), with openHAB. It supports
+ both controlling the player and retrieving player status data like the currently playing movie title.
+
+
+
+ local
+
+
+
+
+
+ mdns
+
+
+ mdnsServiceType
+ _emby-server._tcp.local.
+
+
+
+
+
+
+ ip
+
+
+ type
+ ipBroadcast
+
+
+ destPort
+ 7359
+
+
+ request
+ who is EmbyServer?
+
+
+ timeoutMs
+ 5000
+
+
+
+
+
+ response
+ .*"Id".*
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties
new file mode 100644
index 00000000000..99cde2f1937
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties
@@ -0,0 +1,125 @@
+# add-on
+
+addon.emby.name = Emby Binding
+addon.emby.description = The Emby Binding integrates Emby, a personal media server (https://emby.media/), with openHAB. It supports both controlling the player and retrieving player status data like the currently playing movie title.
+
+@text/thing.status.device.config.invalidImageType
+# thing status messages
+thing.status.bridge.connectionLost = "Connection Lost"
+thing.status.bridge.connectionFailed = "Connection Failed: "
+thing.status.bridge.configurationFailed = "Configuration error: "
+thing.status.bridge.connectionRetry = "Retrying Connection: "
+thing.status.bridge.missingAPI = "Invalid API key"
+thing.status.bridge.missingIP = "Invalid Server IP"
+thing.status.device.noBridge = "No Bridge Selected"
+thing.status.device.bridgeOffline = "Linked Bridge Offline"
+thing.status.device.configInValid = "Invalid Config: "
+thing.status.device.initalizationFalied = "Initialization failed"
+thing.status.device.config.noDeviceID = "No DeviceID"
+thing.status.device.config.noChannelDefined = "Missing Channel: "
+thing.status.device.config.invalidImageType = "Invalid Image Type: "
+thing.status.device.config.notNumber = "Number Required for: "
+thing.status.device.config.booleanRequried = "Boolean Required: "
+
+# thing types
+
+thing-type.emby.controller.label = EMBY Server
+thing-type.emby.controller.description = This is the Bridge to an instance of an EMBY server you want to connect to.
+thing-type.emby.device.label = EMBY Binding Device
+thing-type.emby.device.description = This is a player device which connects to an EMBY server to play files that you want to montior.
+
+# thing types config
+
+thing-type.config.emby.controller.api.label = API Key
+thing-type.config.emby.controller.api.description = This is the API key generated from EMBY used for Authorization.
+thing-type.config.emby.controller.discovery.label = Discovery
+thing-type.config.emby.controller.discovery.description = If set to false the controller will not add new things from devices to the inbox.
+thing-type.config.emby.controller.ipAddress.label=IP Address
+thing-type.config.emby.controller.ipAddress.description = This is the ip address of the EMBY Server.
+thing-type.config.emby.controller.port.label = Port
+thing-type.config.emby.controller.port.description = This is the port of the EMBY server.
+thing-type.config.emby.controller.refreshInterval.label = Refresh Parameter
+thing-type.config.emby.controller.refreshInterval.description = This is the refresh interval in milliseconds that will be sent to the websocket. Default is 10,000
+thing-type.config.emby.device.deviceID.label = DeviceID
+thing-type.config.emby.device.deviceID.description = This is the deviceId you want to connect to to monitor.
+
+# channel types
+
+channel-type.emby.control.label = Control
+channel-type.emby.control.description = Control the Emby Player, e.g. start/stop/next/previous/ffward/rewind
+channel-type.emby.current-time.label = Current Time
+channel-type.emby.current-time.description = Current time of currently playing media
+channel-type.emby.duration.label = Duration
+channel-type.emby.duration.description = Length of currently playing media
+channel-type.emby.image-url.label=Image URL
+channel-type.emby.image-url.description = The url of the playing media
+channel-type.emby.media-type.label = Media Type
+channel-type.emby.media-type.description = Media type of the current file
+channel-type.emby.mute.label = Mute
+channel-type.emby.mute.description = Mute/unmute your device
+channel-type.emby.show-title.label = Show Title
+channel-type.emby.show-title.description = Title of the current show
+channel-type.emby.stop.label = Stop
+channel-type.emby.stop.description = Stops the player. ON if the player is stopped.
+channel-type.emby.title.label = Title
+channel-type.emby.title.description = Title of the current song
+
+# channel types config
+
+channel-type.config.emby.image-url.image-url_maxHeight.label = Image Max Height
+channel-type.config.emby.image-url.image-url_maxHeight.description = The maximum height of the image that will be retrieved.
+channel-type.config.emby.image-url.image-url_maxWidth.label = Image Max Width
+channel-type.config.emby.image-url.image-url_maxWidth.description = The maximum width of the image that will be retrieved.
+channel-type.config.emby.image-url.image-url_type.option.Primary = Primary
+channel-type.config.emby.image-url.image-url_type.option.Art = Art
+channel-type.config.emby.image-url.image-url_type.option.Backdrop = Backdrop
+channel-type.config.emby.image-url.image-url_type.option.Banner = Banner
+channel-type.config.emby.image-url.image-url_type.option.Logo = Logo
+channel-type.config.emby.image-url.image-url_type.option.Thumb = Thumb
+channel-type.config.emby.image-url.image-url_type.option.Disc = Disc
+channel-type.config.emby.image-url.image-url_type.option.Box = Box
+channel-type.config.emby.image-url.image-url_type.option.Screenshot = Screenshot
+channel-type.config.emby.image-url.image-url_type.option.Menu = Menu
+channel-type.config.emby.image-url.image-url_type.option.Chapter = Chapter
+
+ # SendPlay action
+action.emby.SendPlay.label = Send Play Command
+action.emby.SendPlay.desc = Send a play command to an Emby player device.
+
+# SendPlay inputs
+action.emby.SendPlay.input.itemIds.label = Item IDs
+action.emby.SendPlay.input.itemIds.desc = Comma-delimited list of item IDs to play (e.g. "id1,id2,id3").
+action.emby.SendPlay.input.playCommand.label = Play Command
+action.emby.SendPlay.input.playCommand.desc = One of PlayNow, PlayNext or PlayLast.
+action.emby.SendPlay.input.startPositionTicks.label = Start Position (ticks)
+action.emby.SendPlay.input.startPositionTicks.desc = Starting offset (in ticks) for the first title.
+action.emby.SendPlay.input.mediaSourceId.label = Media Source ID
+action.emby.SendPlay.input.mediaSourceId.desc = Media source to use for the first item.
+action.emby.SendPlay.input.audioStreamIndex.label = Audio Stream Index
+action.emby.SendPlay.input.audioStreamIndex.desc = Audio stream index for the first item.
+action.emby.SendPlay.input.subtitleStreamIndex.label = Subtitle Stream Index
+action.emby.SendPlay.input.subtitleStreamIndex.desc = Subtitle stream index for the first item.
+action.emby.SendPlay.input.startIndex.label = Start Index
+action.emby.SendPlay.input.startIndex.desc = Zero-based index of which item in the list to start playback at.
+
+# SendGeneralCommand action
+action.emby.SendGeneralCommand.label = Send General Command
+action.emby.SendGeneralCommand.desc = Send one of the generic Emby control commands (no arguments).
+
+# SendGeneralCommand inputs
+action.emby.SendGeneralCommand.input.commandName.label = Command Name
+action.emby.SendGeneralCommand.input.commandName.desc = The general command to send (e.g. MoveUp, ToggleMute, GoHome).
+
+# SendGeneralCommandWithArgs action
+action.emby.SendGeneralCommandWithArgs.label = Send General Command With Arguments
+action.emby.SendGeneralCommandWithArgs.desc = Send a generic Emby control command plus any required arguments.
+
+# SendGeneralCommandWithArgs inputs
+action.emby.SendGeneralCommandWithArgs.input.commandName.label = Command Name
+action.emby.SendGeneralCommandWithArgs.input.commandName.desc = The general command to send (e.g. SetVolume, DisplayMessage).
+action.emby.SendGeneralCommandWithArgs.input.jsonArguments.label = Arguments
+action.emby.SendGeneralCommandWithArgs.input.jsonArguments.desc = Comma-delimited values, depending on the command (e.g. "Volume", "Header,Text,TimeoutMs").
+
+
+
+
diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 00000000000..f5a5bd7c64b
--- /dev/null
+++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,155 @@
+
+
+
+
+
+ This is the Bridge to an instance of an EMBY server you want to connect to.
+ NetworkAppliance
+
+
+
+ This is the API key generated from EMBY used for Authorization.
+
+
+
+ IP address or hostname of the EMBY server.
+ network-address
+
+
+
+ Port number for the EMBY server.
+ 8096
+
+
+
+ Polling interval for play-state updates.
+ 10000
+
+
+
+ Enable or disable automatic device discovery.
+ true
+ true
+
+
+
+
+
+
+
+
+
+ This is a player device which connects to an EMBY server.
+ MediaPlayer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is the deviceId you want to connect to.
+
+
+
+
+ String
+
+ Title of the current song
+
+
+
+
+ String
+
+ Title of the current show
+
+
+
+
+ Player
+
+ Control the Emby Player, e.g. start/stop/next/previous/ffward/rewind
+ Player
+
+
+
+ Switch
+
+ Stops the player. ON if the player is stopped.
+
+
+
+ Switch
+
+ Mute/unmute your device
+
+
+
+ Number:Time
+
+ Current time of currently playing media
+
+
+
+
+ Number:Time
+
+ Length of currently playing media
+
+
+
+
+ String
+
+ Media type of the current file
+
+
+
+ String
+
+ The url of the playing media
+
+
+
+ Primary
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The maximum height of the image that will be retrieved.
+
+
+
+ The maximum width of the image that will be retrieved.
+
+
+
+ If set to true, a percent played overlay will be added to the image. For example, using
+ PercentPlayed=47 will overlay a 47% progress indicator. Default is false.
+
+
+
+
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 6378d8c8be0..0f898fb54ee 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -137,6 +137,7 @@
org.openhab.binding.electroluxappliance
org.openhab.binding.elerotransmitterstick
org.openhab.binding.elroconnects
+ org.openhab.binding.emby
org.openhab.binding.emotiva
org.openhab.binding.energenie
org.openhab.binding.energidataservice