Compare commits
11 Commits
86b410235e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
8cf2447667
|
|||
|
1bd3fab67a
|
|||
|
|
363004fc99 | ||
|
|
3af45732a8 | ||
|
|
ce81d74ab1 | ||
|
|
97bc934030 | ||
|
|
456e662578 | ||
|
|
138d2ec2fe | ||
|
|
8e81cd8688 | ||
|
|
2a67f93990 | ||
|
|
28b6c0798e |
138
README.md
138
README.md
@@ -1,86 +1,94 @@
|
||||
# Dispatcharr Integration for Home Assistant
|
||||
|
||||
This is a custom integration for Home Assistant that monitors active streams from a Dispatcharr server. It provides sensors to track the total number of streams and detailed information for each individual stream.
|
||||
[](https://github.com/custom-components/hacs)
|
||||
|
||||
This is a custom integration for [Home Assistant](https://www.home-assistant.io/) that connects to your [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr) instance. It provides real-time monitoring of active streams, creating dynamic media player entities for each stream and a summary sensor for the total count.
|
||||
|
||||
## Features
|
||||
|
||||
- Provides a sensor showing the total number of active streams (`sensor.total_active_streams`).
|
||||
- Dynamically creates a unique sensor for each active stream, which is automatically removed when the stream stops.
|
||||
- Pulls detailed program guide (EPG) information for each active stream, including program/episode titles and numbers.
|
||||
- Displays stream-specific details like resolution, codecs, and client count.
|
||||
- Includes an option to disable EPG fetching to reduce server load.
|
||||
* **Total Stream Count:** A dedicated sensor (`sensor.dispatcharr_total_active_streams`) that shows the total number of currently active streams.
|
||||
* **Dynamic Media Player Entities:** Creates a new media player entity for each active stream automatically. These entities are removed when the stream stops, keeping your Home Assistant instance clean.
|
||||
* **Rich Stream Data:** Each media player provides detailed attributes, including channel name, client count, video/audio codecs, and more.
|
||||
* **Live Program Information (Optional):** Parses your EPG data to display the currently airing program's title, description, and times as media player attributes. This feature can be disabled for performance.
|
||||
|
||||
## Installation and Configuration
|
||||
## Prerequisites
|
||||
|
||||
### Initial Setup
|
||||
* An understanding and acceptance that AI helped me make this. If that is not your thang... don't use it.
|
||||
* A running instance of [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr).
|
||||
* [Home Assistant Community Store (HACS)](https://hacs.xyz/) installed on your Home Assistant instance.
|
||||
* The username and password for your Dispatcharr user account.
|
||||
|
||||
1. Copy the `dispatcharr_sensor` directory into your Home Assistant `<config>/custom_components/` directory.
|
||||
2. Restart Home Assistant.
|
||||
3. Go to **Settings** > **Devices & Services** > **Add Integration**.
|
||||
4. Search for "Dispatcharr" and select it.
|
||||
5. In the configuration dialog, enter the following information:
|
||||
- **Host:** The IP address of your Dispatcharr server (e.g., `192.168.0.121`).
|
||||
- **Port:** The port your Dispatcharr server is running on (e.g., `9191`).
|
||||
- **Username:** Your Dispatcharr username.
|
||||
- **Password:** Your Dispatcharr password.
|
||||
## Installation via HACS
|
||||
|
||||
### Optional Settings
|
||||
This integration is not yet in the default HACS repository. You can add it as a custom repository.
|
||||
|
||||
To reduce the load on your server, especially on systems with limited resources, you can disable the fetching of detailed EPG (program guide) data. When disabled, sensors for active streams will still be created, but program-related attributes (`program_title`, `episode_title`, etc.) will not be populated.
|
||||
1. In Home Assistant, go to **HACS** > **Integrations**.
|
||||
2. Click the three dots in the top-right corner and select **"Custom repositories"**.
|
||||
3. In the "Repository" field, enter the URL to this GitHub repository: `https://github.com/lyfesaver74/ha-dispatcharr`
|
||||
4. For the "Category" dropdown, select **"Integration"**.
|
||||
5. Click **"Add"**.
|
||||
6. You should now see the "Dispatcharr Integration" in your HACS integrations list. Click **"Install"** and proceed with the installation.
|
||||
7. Restart Home Assistant when prompted.
|
||||
|
||||
To change this setting:
|
||||
1. Navigate to **Settings** > **Devices & Services**.
|
||||
2. Find your Dispatcharr integration and click **Configure**.
|
||||
3. A dialog box will appear. Uncheck the box labeled **"Enable EPG Program Data"** to disable it.
|
||||
4. Click **Submit**.
|
||||
## Configuration
|
||||
|
||||
## Sensors Provided
|
||||
Once the integration is installed, you can add it to Home Assistant via the UI.
|
||||
|
||||
### Total Active Streams Sensor
|
||||
1. Go to **Settings** > **Devices & Services**.
|
||||
2. Click the **"+ Add Integration"** button in the bottom right.
|
||||
3. Search for **"Dispatcharr"** and select it.
|
||||
4. A configuration dialog will appear. Enter the following information:
|
||||
* **Host:** The IP address of your Dispatcharr server (e.g., `192.168.0.121`).
|
||||
* **Port:** The port your Dispatcharr server is running on (default is `9191`).
|
||||
* **Username:** Your Dispatcharr username.
|
||||
* **Password:** Your Dispatcharr password.
|
||||
5. Click **"Submit"**. The integration will be set up and your entities will be created.
|
||||
|
||||
A single sensor that provides a numeric count of the total active streams.
|
||||
## Optional Configuration (After Installation)
|
||||
|
||||
- **Entity ID:** `sensor.total_active_streams`
|
||||
- **State:** A number representing the count of active streams (e.g., `2`).
|
||||
The EPG data feature can be turned on or off at any time without re-installing the integration. This is useful for performance tuning on slower servers.
|
||||
|
||||
### Individual Stream Sensors
|
||||
1. Go to **Settings** > **Devices & Services**.
|
||||
2. Find the Dispatcharr integration and click **"Configure"**.
|
||||
3. Check or uncheck the **"Enable EPG Data"** option.
|
||||
4. Click **"Submit"**. The integration will automatically reload with the new setting.
|
||||
|
||||
These sensors are created on-the-fly when a stream starts and are removed when it stops.
|
||||
## Provided Entities
|
||||
|
||||
- **Entity ID:** Will be generated based on the channel name, like `sensor.dispatcharr_amc`.
|
||||
- **State:** "Streaming"
|
||||
- **Attributes:**
|
||||
- `channel_number`: The channel number from the EPG guide (e.g., `102`).
|
||||
- `channel_name`: The display name of the channel (e.g., `AMC`).
|
||||
- `program_title`: The title of the currently airing program.
|
||||
- `episode_title`: The title of the specific episode, if available.
|
||||
- `episode_number`: The season/episode number (e.g., `S1E18`), if available.
|
||||
- `program_description`: The description of the current program.
|
||||
- `program_start`: The start time of the current program (ISO format).
|
||||
- `program_stop`: The end time of the current program (ISO format).
|
||||
- `clients`: The number of clients watching the stream.
|
||||
- `resolution`: The resolution of the stream (e.g., `1280x720`).
|
||||
- `fps`: The frame rate of the stream.
|
||||
- `video_codec`: The video codec being used.
|
||||
- `audio_codec`: The audio codec being used.
|
||||
- `avg_bitrate`: The average bitrate of the stream.
|
||||
The integration will create the following entities:
|
||||
|
||||
## Example Lovelace Card
|
||||
* **`sensor.dispatcharr_total_active_streams`**: A sensor that always exists and shows the total number of active streams. Its state is a number (e.g., `0`, `1`, `2`).
|
||||
* **`media_player.dispatcharr_<channel_name>`** (Dynamic): A new media player entity will be created for each active stream. The entity ID is based on the channel name.
|
||||
* The state will be `playing` when active.
|
||||
* These entities will be removed automatically when no longer active.
|
||||
|
||||
You can use a Markdown card in your Home Assistant dashboard to display a clean summary of all active streams.
|
||||
### Media Player Attributes
|
||||
|
||||
Each dynamic media player entity will have the following attributes:
|
||||
|
||||
| Attribute | Description | Example |
|
||||
|---|---|---|
|
||||
| `media_title` | The title of the currently airing program. | `Doctor Who` |
|
||||
| `media_series_title` | The friendly name of the channel. | `US: BBC AMERICA HD` |
|
||||
| `media_content_id` | The stream's internal channel number/ID. | `98209` |
|
||||
| `app_name` | The source of the stream. | `Dispatcharr` |
|
||||
| `entity_picture` | A direct URL to the channel's logo image. | `http://.../logos/262/cache/` |
|
||||
| `clients` | The number of clients watching this stream. | `1` |
|
||||
| `resolution` | The current video resolution. | `1280x720` |
|
||||
| `fps` | The current frames per second. | `59.94` |
|
||||
| `video_codec` | The video codec being used. | `h264` |
|
||||
| `audio_codec` | The audio codec being used. | `aac` |
|
||||
| `avg_bitrate` | The average bitrate of the stream. | `4.11 Mbps` |
|
||||
| `program_description` | A description of the current program. | `The Doctor travels through time...` |
|
||||
| `program_start` | The start time of the current program. | `2025-10-02T14:00:00-05:00` |
|
||||
| `program_stop` | The end time of the current program. | `2025-10-02T15:00:00-05:00` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* **Program Data is `null`:** If the `media_title` and other program attributes are `null` (and the EPG option is enabled), it means the integration was unable to find matching guide data for that specific channel in your Dispatcharr EPG file. Please ensure that the channel has EPG data assigned within the Dispatcharr UI and that your EPG source has been recently refreshed.
|
||||
* **Authentication Errors:** If you receive errors after setup, double-check that your Dispatcharr username and password are correct.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the `LICENSE` file for details.
|
||||
|
||||
```yaml
|
||||
type: markdown
|
||||
title: Active Dispatcharr Streams
|
||||
content: |
|
||||
{% for stream in states.sensor | selectattr('attributes.channel_name', 'defined') | selectattr('entity_id', 'search', 'dispatcharr_') %}
|
||||
**{{ stream.attributes.channel_name }} ({{ stream.attributes.channel_number }})**
|
||||
*Now Playing:* {{ stream.attributes.program_title }}
|
||||
{% if stream.attributes.episode_title %}
|
||||
*Episode:* {{ stream.attributes.episode_title }} ({{ stream.attributes.episode_number }})
|
||||
{% endif %}
|
||||
*Clients:* {{ stream.attributes.clients }} | *Resolution:* {{ stream.attributes.resolution }}
|
||||
***
|
||||
{% else %}
|
||||
No active streams.
|
||||
{% endif %}
|
||||
|
||||
@@ -17,14 +17,13 @@ from homeassistant.util import slugify
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.MEDIA_PLAYER]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Dispatcharr from a config entry."""
|
||||
coordinator = DispatcharrDataUpdateCoordinator(hass, entry)
|
||||
|
||||
# Perform initial data population. This will raise ConfigEntryNotReady on failure.
|
||||
await coordinator.async_populate_channel_details()
|
||||
await coordinator.async_populate_channel_map_from_xml()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
@@ -32,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
@@ -46,7 +46,7 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
self.config_entry = config_entry
|
||||
self.websession = async_get_clientsession(hass)
|
||||
self._access_token: str | None = None
|
||||
self.channel_details: dict = {}
|
||||
self.channel_map: dict = {}
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30)
|
||||
@@ -98,38 +98,96 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"API request to {url} failed: {err}") from err
|
||||
|
||||
async def async_populate_channel_details(self):
|
||||
"""Fetch all channel details to build a lookup map."""
|
||||
_LOGGER.info("Populating Dispatcharr channel details")
|
||||
try:
|
||||
all_channels = await self._api_request("GET", f"{self.base_url}/api/channels/channels/")
|
||||
if isinstance(all_channels, list):
|
||||
self.channel_details = {
|
||||
channel['uuid']: channel for channel in all_channels if 'uuid' in channel
|
||||
}
|
||||
else:
|
||||
_LOGGER.warning("Expected a list of channels, but received: %s", type(all_channels))
|
||||
self.channel_details = {}
|
||||
_LOGGER.debug("Found %d channels", len(self.channel_details))
|
||||
except Exception as e:
|
||||
_LOGGER.error("Could not populate channel details: %s", e)
|
||||
raise ConfigEntryNotReady(f"Could not fetch channel details: {e}") from e
|
||||
|
||||
async def _get_current_programs_from_xml(self, numeric_channel_ids: list[str]) -> dict:
|
||||
"""Get current program for EPG IDs by parsing the raw XMLTV file."""
|
||||
if not numeric_channel_ids:
|
||||
return {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
async def async_populate_channel_map_from_xml(self):
|
||||
"""Fetch the XML file once to build a reliable map of channels."""
|
||||
_LOGGER.info("Populating Dispatcharr channel map from XML file...")
|
||||
try:
|
||||
xml_string = await self._api_request("GET", f"{self.base_url}/output/epg", is_json=False)
|
||||
root = ET.fromstring(xml_string)
|
||||
except UpdateFailed as err:
|
||||
raise ConfigEntryNotReady(f"Could not fetch EPG XML file to build channel map: {err}") from err
|
||||
|
||||
found_programs, channels_to_find = {}, set(numeric_channel_ids)
|
||||
for program in root.iterfind("programme"):
|
||||
if not channels_to_find: break
|
||||
channel_id_str = program.get("channel")
|
||||
if channel_id_str in channels_to_find:
|
||||
try:
|
||||
root = ET.fromstring(xml_string)
|
||||
self.channel_map = {}
|
||||
for channel in root.iterfind("channel"):
|
||||
display_name = channel.findtext("display-name")
|
||||
channel_id = channel.get("id")
|
||||
icon_tag = channel.find("icon")
|
||||
icon_url = icon_tag.get("src") if icon_tag is not None else None
|
||||
|
||||
if display_name and channel_id:
|
||||
slug_name = slugify(display_name)
|
||||
self.channel_map[slug_name] = {"id": channel_id, "name": display_name, "logo_url": icon_url}
|
||||
|
||||
if not self.channel_map:
|
||||
raise ConfigEntryNotReady("XML was fetched, but no channels could be mapped.")
|
||||
|
||||
_LOGGER.info("Successfully built channel map with %d entries.", len(self.channel_map))
|
||||
except ET.ParseError as e:
|
||||
_LOGGER.error("Failed to parse XML for channel map: %s", e)
|
||||
raise ConfigEntryNotReady(f"Failed to parse XML for channel map: {e}") from e
|
||||
|
||||
def _get_channel_details_from_stream_name(self, stream_name: str) -> dict | None:
|
||||
"""(REWRITTEN) Match a stream name to a channel in the map, preferring the longest match."""
|
||||
if not stream_name:
|
||||
return None
|
||||
|
||||
simple_stream_name = slugify(re.sub(r'^\w+:\s*|\s+HD$', '', stream_name, flags=re.IGNORECASE))
|
||||
_LOGGER.debug("Attempting to match simplified stream name: '%s'", simple_stream_name)
|
||||
|
||||
# 1. Try for a direct, exact match first (most reliable)
|
||||
if simple_stream_name in self.channel_map:
|
||||
_LOGGER.debug("Found exact match for '%s'", simple_stream_name)
|
||||
return self.channel_map[simple_stream_name]
|
||||
|
||||
# 2. If no exact match, find all possible substring matches
|
||||
possible_matches = []
|
||||
for slug_key, details in self.channel_map.items():
|
||||
if slug_key in simple_stream_name:
|
||||
possible_matches.append((slug_key, details))
|
||||
|
||||
# 3. If any matches were found, sort them by length and return the longest one
|
||||
if possible_matches:
|
||||
_LOGGER.debug("Found possible matches: %s", [m[0] for m in possible_matches])
|
||||
# Sort by the length of the key (item[0]), descending, and return the details of the best match
|
||||
best_match = sorted(possible_matches, key=lambda item: len(item[0]), reverse=True)[0]
|
||||
_LOGGER.debug("Selected best match: '%s'", best_match[0])
|
||||
return best_match[1]
|
||||
|
||||
_LOGGER.debug("Could not find any match for stream name: '%s'", stream_name)
|
||||
return None
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data by fetching from authenticated endpoints."""
|
||||
status_data = await self._api_request("GET", f"{self.base_url}/proxy/ts/status")
|
||||
active_streams = status_data.get("channels", [])
|
||||
if not active_streams: return {}
|
||||
|
||||
xml_string = await self._api_request("GET", f"{self.base_url}/output/epg", is_json=False)
|
||||
try:
|
||||
root = ET.fromstring(xml_string)
|
||||
except ET.ParseError as e:
|
||||
_LOGGER.error("Could not parse EPG XML on update: %s", e)
|
||||
return self.data
|
||||
|
||||
enriched_streams = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for stream in active_streams:
|
||||
stream_uuid = stream.get("channel_id")
|
||||
stream_name = stream.get("stream_name")
|
||||
if not stream_uuid or not stream_name: continue
|
||||
|
||||
details = self._get_channel_details_from_stream_name(stream_name)
|
||||
enriched_stream = stream.copy()
|
||||
|
||||
if details:
|
||||
xmltv_id = details["id"]
|
||||
enriched_stream["xmltv_id"] = xmltv_id
|
||||
enriched_stream["channel_name"] = details["name"]
|
||||
enriched_stream["logo_url"] = details.get("logo_url")
|
||||
|
||||
for program in root.iterfind(f".//programme[@channel='{xmltv_id}']"):
|
||||
start_str, stop_str = program.get("start"), program.get("stop")
|
||||
if start_str and stop_str:
|
||||
try:
|
||||
@@ -137,47 +195,16 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
stop_time = datetime.strptime(stop_str, "%Y%m%d%H%M%S %z")
|
||||
if start_time <= now < stop_time:
|
||||
episode_num_tag = program.find("episode-num[@system='onscreen']")
|
||||
found_programs[channel_id_str] = {
|
||||
"title": program.findtext("title"),
|
||||
"description": program.findtext("desc"),
|
||||
"start_time": start_time.isoformat(),
|
||||
"end_time": stop_time.isoformat(),
|
||||
enriched_stream["program"] = {
|
||||
"title": program.findtext("title"), "description": program.findtext("desc"),
|
||||
"start_time": start_time.isoformat(), "end_time": stop_time.isoformat(),
|
||||
"subtitle": program.findtext("sub-title"),
|
||||
"episode_num": episode_num_tag.text if episode_num_tag is not None else None,
|
||||
}
|
||||
channels_to_find.remove(channel_id_str)
|
||||
except (ValueError, TypeError): continue
|
||||
return found_programs
|
||||
except (UpdateFailed, ET.ParseError) as e:
|
||||
_LOGGER.warning("Could not get or parse EPG XML file, program info will be unavailable: %s", e)
|
||||
return {}
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data via authenticated API calls."""
|
||||
status_data = await self._api_request("GET", f"{self.base_url}/proxy/ts/status")
|
||||
active_streams = status_data.get("channels", [])
|
||||
if not active_streams: return {}
|
||||
|
||||
current_programs_map = {}
|
||||
if self.config_entry.options.get("enable_epg", True):
|
||||
active_numeric_ids = list(set([
|
||||
str(int(details['channel_number']))
|
||||
for stream in active_streams
|
||||
if (details := self.channel_details.get(stream['channel_id'])) and details.get('channel_number') is not None
|
||||
]))
|
||||
if active_numeric_ids:
|
||||
current_programs_map = await self._get_current_programs_from_xml(active_numeric_ids)
|
||||
|
||||
enriched_streams = {}
|
||||
for stream in active_streams:
|
||||
stream_uuid = stream['channel_id']
|
||||
enriched_stream = stream.copy()
|
||||
details = self.channel_details.get(stream_uuid)
|
||||
if details:
|
||||
if logo_id := details.get("logo_id"):
|
||||
enriched_stream["logo_url"] = f"{self.base_url}/api/channels/logos/{logo_id}/cache/"
|
||||
if numeric_id_float := details.get("channel_number"):
|
||||
numeric_id_str = str(int(numeric_id_float))
|
||||
enriched_stream["program"] = current_programs_map.get(numeric_id_str)
|
||||
enriched_streams[stream_uuid] = enriched_stream
|
||||
|
||||
return enriched_streams
|
||||
|
||||
@@ -14,6 +14,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("host"): str,
|
||||
vol.Required("port", default=9191): int,
|
||||
vol.Optional("ssl",default=False): bool,
|
||||
vol.Required("username"): str,
|
||||
vol.Required("password"): str,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Constants for the Dispatcharr Sensor integration."""
|
||||
|
||||
DOMAIN = "dispatcharr_sensor"
|
||||
|
||||
# List of platforms to support. In this case, just the "sensor" platform.
|
||||
PLATFORMS = ["sensor"]
|
||||
# Add BUTTON and MEDIA_PLAYER explicitly
|
||||
PLATFORMS = ["sensor", "media_player", "button"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "dispatcharr_sensor",
|
||||
"name": "Dispatcharr Sessions Sensor",
|
||||
"version": "2.2.0",
|
||||
"version": "3.0.0",
|
||||
"documentation": "https://github.com/lyfesaver74/ha-dispatcharr",
|
||||
"requirements": [],
|
||||
"dependencies": [],
|
||||
@@ -10,3 +10,5 @@
|
||||
"config_flow": true
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
118
custom_components/dispatcharr_sensor/media_player.py
Normal file
118
custom_components/dispatcharr_sensor/media_player.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Media Player platform for Dispatcharr."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import STATE_PLAYING, STATE_IDLE
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the media_player platform."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
DispatcharrStreamManager(coordinator, async_add_entities)
|
||||
|
||||
class DispatcharrStreamManager:
|
||||
"""Manages the creation and removal of media_player entities."""
|
||||
def __init__(self, coordinator, async_add_entities):
|
||||
self._coordinator = coordinator
|
||||
self._async_add_entities = async_add_entities
|
||||
self._known_stream_ids = set()
|
||||
self._coordinator.async_add_listener(self._update_entities)
|
||||
|
||||
@callback
|
||||
def _update_entities(self) -> None:
|
||||
if self._coordinator.data is None:
|
||||
current_stream_ids = set()
|
||||
else:
|
||||
current_stream_ids = set(self._coordinator.data.keys())
|
||||
|
||||
new_stream_ids = current_stream_ids - self._known_stream_ids
|
||||
if new_stream_ids:
|
||||
new_entities = [DispatcharrStreamMediaPlayer(self._coordinator, stream_id) for stream_id in new_stream_ids]
|
||||
self._async_add_entities(new_entities)
|
||||
self._known_stream_ids.update(new_stream_ids)
|
||||
|
||||
class DispatcharrStreamMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
"""Dispatcharr stream player."""
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
# Feature: Allow stopping the stream
|
||||
_attr_supported_features = MediaPlayerEntityFeature.STOP
|
||||
|
||||
def __init__(self, coordinator, stream_id: str):
|
||||
super().__init__(coordinator)
|
||||
self._stream_id = stream_id
|
||||
|
||||
stream_data = self.coordinator.data.get(self._stream_id) or {}
|
||||
name = stream_data.get("channel_name", stream_data.get("stream_name", f"Stream {self._stream_id[-6:]}"))
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._stream_id}"
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Dispatcharr")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self.coordinator.data is not None and self._stream_id in self.coordinator.data
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send a command to Dispatcharr to kill this stream."""
|
||||
_LOGGER.info("Stopping stream %s via API", self._stream_id)
|
||||
try:
|
||||
# Uses the DELETE endpoint defined in Swagger
|
||||
await self.coordinator.api_request("DELETE", f"/proxy/ts/stop/{self._stream_id}")
|
||||
# Optimistic state update
|
||||
self._attr_state = STATE_IDLE
|
||||
self.async_write_ha_state()
|
||||
# Force refresh to clear the entity
|
||||
await self.coordinator.async_request_refresh()
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to stop stream: %s", e)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
if not self.available:
|
||||
self._attr_state = STATE_IDLE
|
||||
self.async_write_ha_state()
|
||||
# If stream is gone, the Manager will eventually handle cleanup,
|
||||
# but for now we just show idle.
|
||||
return
|
||||
|
||||
stream_data = self.coordinator.data[self._stream_id]
|
||||
program_data = stream_data.get("program") or {}
|
||||
|
||||
self._attr_state = STATE_PLAYING
|
||||
self._attr_app_name = "Dispatcharr"
|
||||
self._attr_entity_picture = stream_data.get("logo_url")
|
||||
self._attr_media_content_type = MediaType.TVSHOW
|
||||
self._attr_media_series_title = program_data.get("title")
|
||||
self._attr_media_title = program_data.get("subtitle") or program_data.get("title")
|
||||
|
||||
# Extra attributes
|
||||
self._attr_extra_state_attributes = {
|
||||
"channel_number": stream_data.get("xmltv_id"),
|
||||
"channel_name": stream_data.get("channel_name"),
|
||||
"program_description": program_data.get("description"),
|
||||
"clients": stream_data.get("client_count"),
|
||||
"resolution": stream_data.get("resolution"),
|
||||
"video_codec": stream_data.get("video_codec"),
|
||||
"audio_codec": stream_data.get("audio_codec"),
|
||||
}
|
||||
self.async_write_ha_state()
|
||||
@@ -25,32 +25,10 @@ async def async_setup_entry(
|
||||
except KeyError:
|
||||
raise PlatformNotReady(f"Coordinator not found for entry {config_entry.entry_id}")
|
||||
|
||||
DispatcharrStreamManager(coordinator, async_add_entities)
|
||||
# This platform now ONLY creates the total streams sensor.
|
||||
async_add_entities([DispatcharrTotalStreamSensor(coordinator)])
|
||||
|
||||
|
||||
class DispatcharrStreamManager:
|
||||
"""Manages the creation and removal of stream sensors."""
|
||||
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, async_add_entities: AddEntitiesCallback):
|
||||
self._coordinator = coordinator
|
||||
self._async_add_entities = async_add_entities
|
||||
self._known_stream_ids = set()
|
||||
self._coordinator.async_add_listener(self._update_sensors)
|
||||
|
||||
@callback
|
||||
def _update_sensors(self) -> None:
|
||||
"""Update, add, or remove sensors based on coordinator data."""
|
||||
if self._coordinator.data is None:
|
||||
current_stream_ids = set()
|
||||
else:
|
||||
current_stream_ids = set(self._coordinator.data.keys())
|
||||
|
||||
new_stream_ids = current_stream_ids - self._known_stream_ids
|
||||
if new_stream_ids:
|
||||
new_sensors = [DispatcharrStreamSensor(self._coordinator, stream_id) for stream_id in new_stream_ids]
|
||||
self._async_add_entities(new_sensors)
|
||||
self._known_stream_ids.update(new_stream_ids)
|
||||
|
||||
class DispatcharrTotalStreamSensor(CoordinatorEntity, SensorEntity):
|
||||
"""A sensor to show the total number of active Dispatcharr streams."""
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
@@ -67,59 +45,3 @@ class DispatcharrTotalStreamSensor(CoordinatorEntity, SensorEntity):
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_native_value = len(self.coordinator.data or {})
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class DispatcharrStreamSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a single, dynamic Dispatcharr stream sensor."""
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, stream_id: str):
|
||||
super().__init__(coordinator)
|
||||
self._stream_id = stream_id
|
||||
|
||||
channel_details = coordinator.channel_details.get(stream_id) or {}
|
||||
name = channel_details.get("name", f"Stream {self._stream_id[-6:]}")
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._stream_id}"
|
||||
self._attr_icon = "mdi:television-stream"
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Dispatcharr")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the stream is still in the coordinator's data."""
|
||||
return super().available and self.coordinator.data is not None and self._stream_id in self.coordinator.data
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if not self.available:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
stream_data = self.coordinator.data[self._stream_id]
|
||||
program_data = stream_data.get("program") or {}
|
||||
channel_details = self.coordinator.channel_details.get(self._stream_id) or {}
|
||||
|
||||
self._attr_native_value = "Streaming"
|
||||
self._attr_entity_picture = stream_data.get("logo_url")
|
||||
self._attr_name = channel_details.get("name", self._attr_name)
|
||||
self._attr_extra_state_attributes = {
|
||||
"channel_number": channel_details.get("channel_number"),
|
||||
"channel_name": channel_details.get("name"),
|
||||
"logo_url": stream_data.get("logo_url"),
|
||||
"clients": stream_data.get("client_count"),
|
||||
"resolution": stream_data.get("resolution"),
|
||||
"fps": stream_data.get("source_fps"),
|
||||
"video_codec": stream_data.get("video_codec"),
|
||||
"audio_codec": stream_data.get("audio_codec"),
|
||||
"avg_bitrate": stream_data.get("avg_bitrate"),
|
||||
"program_title": program_data.get("title"),
|
||||
"episode_title": program_data.get("subtitle"),
|
||||
"episode_number": program_data.get("episode_num"),
|
||||
"program_description": program_data.get("description"),
|
||||
"program_start": program_data.get("start_time"),
|
||||
"program_stop": program_data.get("end_time"),
|
||||
}
|
||||
self.async_write_ha_state()
|
||||
|
||||
Reference in New Issue
Block a user