-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add Jellyfin integration #44401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Jellyfin integration #44401
Changes from all commits
869778e
21d9d3d
2b1c8c5
6de1430
c51b1e0
65fed4e
837c2fb
e420dd4
25f52ef
12457f8
75f76c5
1afa735
3075004
cbed5cc
a494779
d60fd3e
9644fd2
661be5f
a3fcc05
5ec0d6d
9b1970f
3e45b28
83435de
dd23a39
4a98c21
5c81506
1720d29
cfdf210
85592eb
b7e25d2
dc05764
5be9da7
d6211ca
8b3dfe8
a0f3bd8
f2defe4
402d484
8bc5932
9adb449
1a065ff
a4bf813
b7b0e14
8eefa9c
8315631
1bd5f1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| """The Jellyfin integration.""" | ||
| import logging | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
|
|
||
| from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input | ||
| from .const import DATA_CLIENT, DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up Jellyfin from a config entry.""" | ||
| hass.data.setdefault(DOMAIN, {}) | ||
|
|
||
| client = create_client() | ||
| try: | ||
| await validate_input(hass, dict(entry.data), client) | ||
| except CannotConnect as ex: | ||
| raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex | ||
| except InvalidAuth: | ||
| _LOGGER.error("Failed to login to Jellyfin server") | ||
| return False | ||
|
RunC0deRun marked this conversation as resolved.
Outdated
|
||
| else: | ||
| hass.data[DOMAIN][entry.entry_id] = {DATA_CLIENT: client} | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| hass.data[DOMAIN].pop(entry.entry_id) | ||
|
|
||
| return True | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """Utility methods for initializing a Jellyfin client.""" | ||
| from __future__ import annotations | ||
|
|
||
| import socket | ||
| from typing import Any | ||
| import uuid | ||
|
|
||
| from jellyfin_apiclient_python import Jellyfin, JellyfinClient | ||
| from jellyfin_apiclient_python.api import API | ||
| from jellyfin_apiclient_python.connection_manager import ( | ||
| CONNECTION_STATE, | ||
| ConnectionManager, | ||
| ) | ||
|
|
||
| from homeassistant import exceptions | ||
| from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .const import CLIENT_VERSION, USER_AGENT, USER_APP_NAME | ||
|
|
||
|
|
||
| async def validate_input( | ||
| hass: HomeAssistant, user_input: dict[str, Any], client: JellyfinClient | ||
| ) -> str: | ||
| """Validate that the provided url and credentials can be used to connect.""" | ||
| url = user_input[CONF_URL] | ||
| username = user_input[CONF_USERNAME] | ||
| password = user_input[CONF_PASSWORD] | ||
|
|
||
| userid = await hass.async_add_executor_job( | ||
| _connect, client, url, username, password | ||
| ) | ||
|
|
||
| return userid | ||
|
|
||
|
|
||
| def create_client() -> JellyfinClient: | ||
| """Create a new Jellyfin client.""" | ||
| jellyfin = Jellyfin() | ||
| client = jellyfin.get_client() | ||
| _setup_client(client) | ||
| return client | ||
|
|
||
|
|
||
| def _setup_client(client: JellyfinClient) -> None: | ||
| """Configure the Jellyfin client with a number of required properties.""" | ||
| player_name = socket.gethostname() | ||
| client_uuid = str(uuid.uuid4()) | ||
|
|
||
| client.config.app(USER_APP_NAME, CLIENT_VERSION, player_name, client_uuid) | ||
| client.config.http(USER_AGENT) | ||
|
|
||
|
|
||
| def _connect(client: JellyfinClient, url: str, username: str, password: str) -> str: | ||
| """Connect to the Jellyfin server and assert that the user can login.""" | ||
| client.config.data["auth.ssl"] = url.startswith("https") | ||
|
|
||
| _connect_to_address(client.auth, url) | ||
| _login(client.auth, url, username, password) | ||
| return _get_id(client.jellyfin) | ||
|
|
||
|
|
||
| def _connect_to_address(connection_manager: ConnectionManager, url: str) -> None: | ||
| """Connect to the Jellyfin server.""" | ||
| state = connection_manager.connect_to_address(url) | ||
| if state["State"] != CONNECTION_STATE["ServerSignIn"]: | ||
| raise CannotConnect | ||
|
|
||
|
|
||
| def _login( | ||
| connection_manager: ConnectionManager, | ||
| url: str, | ||
| username: str, | ||
| password: str, | ||
| ) -> None: | ||
| """Assert that the user can log in to the Jellyfin server.""" | ||
| response = connection_manager.login(url, username, password) | ||
| if "AccessToken" not in response: | ||
| raise InvalidAuth | ||
|
|
||
|
|
||
| def _get_id(api: API) -> str: | ||
| """Set the unique userid from a Jellyfin server.""" | ||
| settings: dict[str, Any] = api.get_user_settings() | ||
| userid: str = settings["Id"] | ||
| return userid | ||
|
|
||
|
|
||
| class CannotConnect(exceptions.HomeAssistantError): | ||
| """Error to indicate the server is unreachable.""" | ||
|
|
||
|
|
||
| class InvalidAuth(exceptions.HomeAssistantError): | ||
| """Error to indicate the credentials are invalid.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| """Config flow for the Jellyfin integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME | ||
| from homeassistant.data_entry_flow import FlowResult | ||
|
|
||
| from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input | ||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| STEP_USER_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_URL): str, | ||
| vol.Required(CONF_USERNAME): str, | ||
| vol.Required(CONF_PASSWORD): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Jellyfin.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle a user defined configuration.""" | ||
| if self._async_current_entries(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we only allow a single entry?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is not a technical limitation. I've added the restriction to make the initial submit as limited in scope as possible. I can work on supporting multiple entries in future versions of the integration. |
||
| return self.async_abort(reason="single_instance_allowed") | ||
|
|
||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None: | ||
| client = create_client() | ||
| try: | ||
| userid = await validate_input(self.hass, user_input, client) | ||
| except CannotConnect: | ||
| errors["base"] = "cannot_connect" | ||
| except InvalidAuth: | ||
| errors["base"] = "invalid_auth" | ||
| except Exception as ex: # pylint: disable=broad-except | ||
| errors["base"] = "unknown" | ||
| _LOGGER.exception(ex) | ||
| else: | ||
| await self.async_set_unique_id(userid) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| return self.async_create_entry( | ||
| title=user_input[CONF_URL], data=user_input | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| """Constants for the Jellyfin integration.""" | ||
|
|
||
| from typing import Final | ||
|
|
||
| DOMAIN: Final = "jellyfin" | ||
|
|
||
| CLIENT_VERSION: Final = "1.0" | ||
|
|
||
| COLLECTION_TYPE_MOVIES: Final = "movies" | ||
| COLLECTION_TYPE_TVSHOWS: Final = "tvshows" | ||
| COLLECTION_TYPE_MUSIC: Final = "music" | ||
|
|
||
| DATA_CLIENT: Final = "client" | ||
|
|
||
| ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType" | ||
| ITEM_KEY_ID: Final = "Id" | ||
| ITEM_KEY_IMAGE_TAGS: Final = "ImageTags" | ||
| ITEM_KEY_INDEX_NUMBER: Final = "IndexNumber" | ||
| ITEM_KEY_MEDIA_SOURCES: Final = "MediaSources" | ||
| ITEM_KEY_MEDIA_TYPE: Final = "MediaType" | ||
| ITEM_KEY_NAME: Final = "Name" | ||
|
|
||
| ITEM_TYPE_ALBUM: Final = "MusicAlbum" | ||
| ITEM_TYPE_ARTIST: Final = "MusicArtist" | ||
| ITEM_TYPE_AUDIO: Final = "Audio" | ||
| ITEM_TYPE_LIBRARY: Final = "CollectionFolder" | ||
|
|
||
| MAX_IMAGE_WIDTH: Final = 500 | ||
| MAX_STREAMING_BITRATE: Final = "140000000" | ||
|
|
||
|
|
||
| MEDIA_SOURCE_KEY_PATH: Final = "Path" | ||
|
|
||
| MEDIA_TYPE_AUDIO: Final = "Audio" | ||
| MEDIA_TYPE_NONE: Final = "" | ||
|
|
||
| SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC] | ||
|
|
||
| USER_APP_NAME: Final = "Home Assistant" | ||
| USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "domain": "jellyfin", | ||
| "name": "Jellyfin", | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/jellyfin", | ||
| "requirements": [ | ||
| "jellyfin-apiclient-python==1.7.2" | ||
| ], | ||
| "iot_class": "local_polling", | ||
| "codeowners": [ | ||
| "@j-stienstra" | ||
| ] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.