mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add Jellyfin integration (#44401)
* Initial commit after scaffold setup * Add initial config flow * Create initial entity * Ready for testing * Can browse, no result yet * Further improvements. Browsing is working. Now need to work on proper stream URL * Two valid URLs. Do not play in HA * First working version for music * Add thumbnail * Includes Artist->Album hierarchy * Add sorting of artists, albums and tracks * Remove code for video libraries * Improved code styling * Optimize configuration flow * Fix unit tests for config flow * Fix import order * Conform to style requirements * Use empty string as media type for non playables * 100% code coverage config_flow * Type async_get_media_source * Final docsctring fix after rebase * Add __init__ and media_source files to .coveragerc * Fix testing issues after rebase * Fix string format issues and relative const import * Remove unused manifest entries * Raise ConfigEntry exceptions, not log errors * Upgrade dependency to avoid WARNING on startup * Change to builtin tuple and list (deprecation) * Log broad exceptions * Add strict typing * Further type fixes after rebase * Retry when cannot connect, otherwise fail setup * Remove unused CONFIG_SCHEMA * Enable strict typing checks * FlowResultDict -> FlowResult * Code quality improvements * Resolve mypy.ini merge conflict * Use unique userid generated by Jellyfin * Update homeassistant/components/jellyfin/config_flow.py Remove connection class from config flow Co-authored-by: Milan Meulemans <milan.meulemans@live.be> * Minor changes for additional checks after rebase * Remove title from string and translations * Changes wrt review * Fixes based on rebase and review suggestions * Move client initialization to separate file * Remove persistent_notification, add test const.py Co-authored-by: Milan Meulemans <milan.meulemans@live.be>
This commit is contained in:
parent
733193b5ad
commit
0ae5b9e880
@ -518,6 +518,8 @@ omit =
|
||||
homeassistant/components/isy994/switch.py
|
||||
homeassistant/components/itach/remote.py
|
||||
homeassistant/components/itunes/media_player.py
|
||||
homeassistant/components/jellyfin/__init__.py
|
||||
homeassistant/components/jellyfin/media_source.py
|
||||
homeassistant/components/joaoapps_join/*
|
||||
homeassistant/components/juicenet/__init__.py
|
||||
homeassistant/components/juicenet/const.py
|
||||
|
@ -66,6 +66,7 @@ homeassistant.components.image_processing.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.integration.*
|
||||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.jellyfin.*
|
||||
homeassistant.components.jewish_calendar.*
|
||||
homeassistant.components.knx.*
|
||||
homeassistant.components.kraken.*
|
||||
|
@ -265,6 +265,7 @@ homeassistant/components/irish_rail_transport/* @ttroy50
|
||||
homeassistant/components/islamic_prayer_times/* @engrbm87
|
||||
homeassistant/components/isy994/* @bdraco @shbatm
|
||||
homeassistant/components/izone/* @Swamp-Ig
|
||||
homeassistant/components/jellyfin/* @j-stienstra
|
||||
homeassistant/components/jewish_calendar/* @tsvi
|
||||
homeassistant/components/juicenet/* @jesserockz
|
||||
homeassistant/components/kaiterra/* @Michsior14
|
||||
|
36
homeassistant/components/jellyfin/__init__.py
Normal file
36
homeassistant/components/jellyfin/__init__.py
Normal file
@ -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
|
||||
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
|
94
homeassistant/components/jellyfin/client_wrapper.py
Normal file
94
homeassistant/components/jellyfin/client_wrapper.py
Normal file
@ -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."""
|
62
homeassistant/components/jellyfin/config_flow.py
Normal file
62
homeassistant/components/jellyfin/config_flow.py
Normal file
@ -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():
|
||||
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
|
||||
)
|
40
homeassistant/components/jellyfin/const.py
Normal file
40
homeassistant/components/jellyfin/const.py
Normal file
@ -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}"
|
13
homeassistant/components/jellyfin/manifest.json
Normal file
13
homeassistant/components/jellyfin/manifest.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
326
homeassistant/components/jellyfin/media_source.py
Normal file
326
homeassistant/components/jellyfin/media_source.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""The Media Source implementation for the Jellyfin integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
|
||||
from jellyfin_apiclient_python.api import jellyfin_url
|
||||
from jellyfin_apiclient_python.client import JellyfinClient
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_CLASS_ALBUM,
|
||||
MEDIA_CLASS_ARTIST,
|
||||
MEDIA_CLASS_DIRECTORY,
|
||||
MEDIA_CLASS_TRACK,
|
||||
)
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.components.media_source.models import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
PlayMedia,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
COLLECTION_TYPE_MUSIC,
|
||||
DATA_CLIENT,
|
||||
DOMAIN,
|
||||
ITEM_KEY_COLLECTION_TYPE,
|
||||
ITEM_KEY_ID,
|
||||
ITEM_KEY_IMAGE_TAGS,
|
||||
ITEM_KEY_INDEX_NUMBER,
|
||||
ITEM_KEY_MEDIA_SOURCES,
|
||||
ITEM_KEY_MEDIA_TYPE,
|
||||
ITEM_KEY_NAME,
|
||||
ITEM_TYPE_ALBUM,
|
||||
ITEM_TYPE_ARTIST,
|
||||
ITEM_TYPE_AUDIO,
|
||||
ITEM_TYPE_LIBRARY,
|
||||
MAX_IMAGE_WIDTH,
|
||||
MAX_STREAMING_BITRATE,
|
||||
MEDIA_SOURCE_KEY_PATH,
|
||||
MEDIA_TYPE_AUDIO,
|
||||
MEDIA_TYPE_NONE,
|
||||
SUPPORTED_COLLECTION_TYPES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up Jellyfin media source."""
|
||||
# Currently only a single Jellyfin server is supported
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
client: JellyfinClient = data[DATA_CLIENT]
|
||||
|
||||
return JellyfinSource(hass, client)
|
||||
|
||||
|
||||
class JellyfinSource(MediaSource):
|
||||
"""Represents a Jellyfin server."""
|
||||
|
||||
name: str = "Jellyfin"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: JellyfinClient) -> None:
|
||||
"""Initialize the Jellyfin media source."""
|
||||
super().__init__(DOMAIN)
|
||||
|
||||
self.hass = hass
|
||||
|
||||
self.client = client
|
||||
self.api = client.jellyfin
|
||||
self.url = jellyfin_url(client, "")
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Return a streamable URL and associated mime type."""
|
||||
media_item = await self.hass.async_add_executor_job(
|
||||
self.api.get_item, item.identifier
|
||||
)
|
||||
|
||||
stream_url = self._get_stream_url(media_item)
|
||||
mime_type = _media_mime_type(media_item)
|
||||
|
||||
return PlayMedia(stream_url, mime_type)
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
"""Return a browsable Jellyfin media source."""
|
||||
if not item.identifier:
|
||||
return await self._build_libraries()
|
||||
|
||||
media_item = await self.hass.async_add_executor_job(
|
||||
self.api.get_item, item.identifier
|
||||
)
|
||||
|
||||
item_type = media_item["Type"]
|
||||
if item_type == ITEM_TYPE_LIBRARY:
|
||||
return await self._build_library(media_item, True)
|
||||
if item_type == ITEM_TYPE_ARTIST:
|
||||
return await self._build_artist(media_item, True)
|
||||
if item_type == ITEM_TYPE_ALBUM:
|
||||
return await self._build_album(media_item, True)
|
||||
|
||||
raise BrowseError(f"Unsupported item type {item_type}")
|
||||
|
||||
async def _build_libraries(self) -> BrowseMediaSource:
|
||||
"""Return all supported libraries the user has access to as media sources."""
|
||||
base = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_type=MEDIA_TYPE_NONE,
|
||||
title=self.name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MEDIA_CLASS_DIRECTORY,
|
||||
)
|
||||
|
||||
libraries = await self._get_libraries()
|
||||
|
||||
base.children = []
|
||||
|
||||
for library in libraries:
|
||||
base.children.append(await self._build_library(library, False))
|
||||
|
||||
return base
|
||||
|
||||
async def _get_libraries(self) -> list[dict[str, Any]]:
|
||||
"""Return all supported libraries a user has access to."""
|
||||
response = await self.hass.async_add_executor_job(self.api.get_media_folders)
|
||||
libraries = response["Items"]
|
||||
result = []
|
||||
for library in libraries:
|
||||
if ITEM_KEY_COLLECTION_TYPE in library:
|
||||
if library[ITEM_KEY_COLLECTION_TYPE] in SUPPORTED_COLLECTION_TYPES:
|
||||
result.append(library)
|
||||
return result
|
||||
|
||||
async def _build_library(
|
||||
self, library: dict[str, Any], include_children: bool
|
||||
) -> BrowseMediaSource:
|
||||
"""Return a single library as a browsable media source."""
|
||||
collection_type = library[ITEM_KEY_COLLECTION_TYPE]
|
||||
|
||||
if collection_type == COLLECTION_TYPE_MUSIC:
|
||||
return await self._build_music_library(library, include_children)
|
||||
|
||||
raise BrowseError(f"Unsupported collection type {collection_type}")
|
||||
|
||||
async def _build_music_library(
|
||||
self, library: dict[str, Any], include_children: bool
|
||||
) -> BrowseMediaSource:
|
||||
"""Return a single music library as a browsable media source."""
|
||||
library_id = library[ITEM_KEY_ID]
|
||||
library_name = library[ITEM_KEY_NAME]
|
||||
|
||||
result = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=library_id,
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_type=MEDIA_TYPE_NONE,
|
||||
title=library_name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
|
||||
if include_children:
|
||||
result.children_media_class = MEDIA_CLASS_ARTIST
|
||||
result.children = await self._build_artists(library_id) # type: ignore[assignment]
|
||||
|
||||
return result
|
||||
|
||||
async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]:
|
||||
"""Return all artists in the music library."""
|
||||
artists = await self._get_children(library_id, ITEM_TYPE_ARTIST)
|
||||
artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return]
|
||||
return [await self._build_artist(artist, False) for artist in artists]
|
||||
|
||||
async def _build_artist(
|
||||
self, artist: dict[str, Any], include_children: bool
|
||||
) -> BrowseMediaSource:
|
||||
"""Return a single artist as a browsable media source."""
|
||||
artist_id = artist[ITEM_KEY_ID]
|
||||
artist_name = artist[ITEM_KEY_NAME]
|
||||
thumbnail_url = self._get_thumbnail_url(artist)
|
||||
|
||||
result = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=artist_id,
|
||||
media_class=MEDIA_CLASS_ARTIST,
|
||||
media_content_type=MEDIA_TYPE_NONE,
|
||||
title=artist_name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=thumbnail_url,
|
||||
)
|
||||
|
||||
if include_children:
|
||||
result.children_media_class = MEDIA_CLASS_ALBUM
|
||||
result.children = await self._build_albums(artist_id) # type: ignore[assignment]
|
||||
|
||||
return result
|
||||
|
||||
async def _build_albums(self, artist_id: str) -> list[BrowseMediaSource]:
|
||||
"""Return all albums of a single artist as browsable media sources."""
|
||||
albums = await self._get_children(artist_id, ITEM_TYPE_ALBUM)
|
||||
albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return]
|
||||
return [await self._build_album(album, False) for album in albums]
|
||||
|
||||
async def _build_album(
|
||||
self, album: dict[str, Any], include_children: bool
|
||||
) -> BrowseMediaSource:
|
||||
"""Return a single album as a browsable media source."""
|
||||
album_id = album[ITEM_KEY_ID]
|
||||
album_title = album[ITEM_KEY_NAME]
|
||||
thumbnail_url = self._get_thumbnail_url(album)
|
||||
|
||||
result = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=album_id,
|
||||
media_class=MEDIA_CLASS_ALBUM,
|
||||
media_content_type=MEDIA_TYPE_NONE,
|
||||
title=album_title,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=thumbnail_url,
|
||||
)
|
||||
|
||||
if include_children:
|
||||
result.children_media_class = MEDIA_CLASS_TRACK
|
||||
result.children = await self._build_tracks(album_id) # type: ignore[assignment]
|
||||
|
||||
return result
|
||||
|
||||
async def _build_tracks(self, album_id: str) -> list[BrowseMediaSource]:
|
||||
"""Return all tracks of a single album as browsable media sources."""
|
||||
tracks = await self._get_children(album_id, ITEM_TYPE_AUDIO)
|
||||
tracks = sorted(tracks, key=lambda k: k[ITEM_KEY_INDEX_NUMBER]) # type: ignore[no-any-return]
|
||||
return [self._build_track(track) for track in tracks]
|
||||
|
||||
def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource:
|
||||
"""Return a single track as a browsable media source."""
|
||||
track_id = track[ITEM_KEY_ID]
|
||||
track_title = track[ITEM_KEY_NAME]
|
||||
mime_type = _media_mime_type(track)
|
||||
thumbnail_url = self._get_thumbnail_url(track)
|
||||
|
||||
result = BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=track_id,
|
||||
media_class=MEDIA_CLASS_TRACK,
|
||||
media_content_type=mime_type,
|
||||
title=track_title,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=thumbnail_url,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _get_children(
|
||||
self, parent_id: str, item_type: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all children for the parent_id whose item type is item_type."""
|
||||
params = {
|
||||
"Recursive": "true",
|
||||
"ParentId": parent_id,
|
||||
"IncludeItemTypes": item_type,
|
||||
}
|
||||
if item_type == ITEM_TYPE_AUDIO:
|
||||
params["Fields"] = ITEM_KEY_MEDIA_SOURCES
|
||||
|
||||
result = await self.hass.async_add_executor_job(self.api.user_items, "", params)
|
||||
return result["Items"] # type: ignore[no-any-return]
|
||||
|
||||
def _get_thumbnail_url(self, media_item: dict[str, Any]) -> str | None:
|
||||
"""Return the URL for the primary image of a media item if available."""
|
||||
image_tags = media_item[ITEM_KEY_IMAGE_TAGS]
|
||||
|
||||
if "Primary" not in image_tags:
|
||||
return None
|
||||
|
||||
item_id = media_item[ITEM_KEY_ID]
|
||||
return str(self.api.artwork(item_id, "Primary", MAX_IMAGE_WIDTH))
|
||||
|
||||
def _get_stream_url(self, media_item: dict[str, Any]) -> str:
|
||||
"""Return the stream URL for a media item."""
|
||||
media_type = media_item[ITEM_KEY_MEDIA_TYPE]
|
||||
|
||||
if media_type == MEDIA_TYPE_AUDIO:
|
||||
return self._get_audio_stream_url(media_item)
|
||||
|
||||
raise BrowseError(f"Unsupported media type {media_type}")
|
||||
|
||||
def _get_audio_stream_url(self, media_item: dict[str, Any]) -> str:
|
||||
"""Return the stream URL for a music media item."""
|
||||
item_id = media_item[ITEM_KEY_ID]
|
||||
user_id = self.client.config.data["auth.user_id"]
|
||||
device_id = self.client.config.data["app.device_id"]
|
||||
api_key = self.client.config.data["auth.token"]
|
||||
|
||||
params = urllib.parse.urlencode(
|
||||
{
|
||||
"UserId": user_id,
|
||||
"DeviceId": device_id,
|
||||
"api_key": api_key,
|
||||
"MaxStreamingBitrate": MAX_STREAMING_BITRATE,
|
||||
}
|
||||
)
|
||||
|
||||
return f"{self.url}Audio/{item_id}/universal?{params}"
|
||||
|
||||
|
||||
def _media_mime_type(media_item: dict[str, Any]) -> str:
|
||||
"""Return the mime type of a media item."""
|
||||
media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0]
|
||||
path = media_source[MEDIA_SOURCE_KEY_PATH]
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
|
||||
if mime_type is not None:
|
||||
return mime_type
|
||||
|
||||
raise BrowseError(f"Unable to determine mime type for path {path}")
|
21
homeassistant/components/jellyfin/strings.json
Normal file
21
homeassistant/components/jellyfin/strings.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
}
|
||||
}
|
21
homeassistant/components/jellyfin/translations/en.json
Normal file
21
homeassistant/components/jellyfin/translations/en.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single Jellyfin server is currently supported"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "URL",
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -147,6 +147,7 @@ FLOWS = [
|
||||
"islamic_prayer_times",
|
||||
"isy994",
|
||||
"izone",
|
||||
"jellyfin",
|
||||
"juicenet",
|
||||
"keenetic_ndms2",
|
||||
"kmtronic",
|
||||
|
11
mypy.ini
11
mypy.ini
@ -737,6 +737,17 @@ no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.jellyfin.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.jewish_calendar.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
@ -899,6 +899,9 @@ iperf3==0.1.11
|
||||
# homeassistant.components.gogogate2
|
||||
ismartgate==4.0.4
|
||||
|
||||
# homeassistant.components.jellyfin
|
||||
jellyfin-apiclient-python==1.7.2
|
||||
|
||||
# homeassistant.components.rest
|
||||
jsonpath==0.82
|
||||
|
||||
|
@ -557,6 +557,9 @@ iotawattpy==0.1.0
|
||||
# homeassistant.components.gogogate2
|
||||
ismartgate==4.0.4
|
||||
|
||||
# homeassistant.components.jellyfin
|
||||
jellyfin-apiclient-python==1.7.2
|
||||
|
||||
# homeassistant.components.rest
|
||||
jsonpath==0.82
|
||||
|
||||
|
1
tests/components/jellyfin/__init__.py
Normal file
1
tests/components/jellyfin/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the jellyfin integration."""
|
17
tests/components/jellyfin/const.py
Normal file
17
tests/components/jellyfin/const.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Constants for the Jellyfin integration tests."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE
|
||||
|
||||
TEST_URL: Final = "https://example.com"
|
||||
TEST_USERNAME: Final = "test-username"
|
||||
TEST_PASSWORD: Final = "test-password"
|
||||
|
||||
MOCK_SUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["ServerSignIn"]}
|
||||
MOCK_SUCCESFUL_LOGIN_RESPONSE: Final = {"AccessToken": "Test"}
|
||||
|
||||
MOCK_UNSUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["Unavailable"]}
|
||||
MOCK_UNSUCCESFUL_LOGIN_RESPONSE: Final = {""}
|
||||
|
||||
MOCK_USER_SETTINGS: Final = {"Id": "123"}
|
164
tests/components/jellyfin/test_config_flow.py
Normal file
164
tests/components/jellyfin/test_config_flow.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Test the jellyfin config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.components.jellyfin.const import DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
MOCK_SUCCESFUL_CONNECTION_STATE,
|
||||
MOCK_SUCCESFUL_LOGIN_RESPONSE,
|
||||
MOCK_UNSUCCESFUL_CONNECTION_STATE,
|
||||
MOCK_UNSUCCESFUL_LOGIN_RESPONSE,
|
||||
MOCK_USER_SETTINGS,
|
||||
TEST_PASSWORD,
|
||||
TEST_URL,
|
||||
TEST_USERNAME,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_abort_if_existing_entry(hass: HomeAssistant):
|
||||
"""Check flow abort when an entry already exist."""
|
||||
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant):
|
||||
"""Test the complete configuration form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address",
|
||||
return_value=MOCK_SUCCESFUL_CONNECTION_STATE,
|
||||
) as mock_connect, patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login",
|
||||
return_value=MOCK_SUCCESFUL_LOGIN_RESPONSE,
|
||||
) as mock_login, patch(
|
||||
"homeassistant.components.jellyfin.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry, patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.API.get_user_settings",
|
||||
return_value=MOCK_USER_SETTINGS,
|
||||
) as mock_set_id:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == TEST_URL
|
||||
assert result2["data"] == {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
}
|
||||
|
||||
assert len(mock_connect.mock_calls) == 1
|
||||
assert len(mock_login.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(mock_set_id.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistant):
|
||||
"""Test we handle an unreachable server."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address",
|
||||
return_value=MOCK_UNSUCCESFUL_CONNECTION_STATE,
|
||||
) as mock_connect:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
assert len(mock_connect.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistant):
|
||||
"""Test that we can handle invalid credentials."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address",
|
||||
return_value=MOCK_SUCCESFUL_CONNECTION_STATE,
|
||||
) as mock_connect, patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login",
|
||||
return_value=MOCK_UNSUCCESFUL_LOGIN_RESPONSE,
|
||||
) as mock_login:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
assert len(mock_connect.mock_calls) == 1
|
||||
assert len(mock_login.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_exception(hass: HomeAssistant):
|
||||
"""Test we handle an unexpected exception during server setup."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address",
|
||||
side_effect=Exception("UnknownException"),
|
||||
) as mock_connect:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
|
||||
assert len(mock_connect.mock_calls) == 1
|
Loading…
x
Reference in New Issue
Block a user