From 0ae5b9e880a5efacd16e66b52719dbaaf85e3cf5 Mon Sep 17 00:00:00 2001
From: j-stienstra <65826735+j-stienstra@users.noreply.github.com>
Date: Fri, 12 Nov 2021 14:57:40 +0100
Subject: [PATCH] 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>
---
 .coveragerc                                   |   2 +
 .strict-typing                                |   1 +
 CODEOWNERS                                    |   1 +
 homeassistant/components/jellyfin/__init__.py |  36 ++
 .../components/jellyfin/client_wrapper.py     |  94 +++++
 .../components/jellyfin/config_flow.py        |  62 ++++
 homeassistant/components/jellyfin/const.py    |  40 +++
 .../components/jellyfin/manifest.json         |  13 +
 .../components/jellyfin/media_source.py       | 326 ++++++++++++++++++
 .../components/jellyfin/strings.json          |  21 ++
 .../components/jellyfin/translations/en.json  |  21 ++
 homeassistant/generated/config_flows.py       |   1 +
 mypy.ini                                      |  11 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/jellyfin/__init__.py         |   1 +
 tests/components/jellyfin/const.py            |  17 +
 tests/components/jellyfin/test_config_flow.py | 164 +++++++++
 18 files changed, 817 insertions(+)
 create mode 100644 homeassistant/components/jellyfin/__init__.py
 create mode 100644 homeassistant/components/jellyfin/client_wrapper.py
 create mode 100644 homeassistant/components/jellyfin/config_flow.py
 create mode 100644 homeassistant/components/jellyfin/const.py
 create mode 100644 homeassistant/components/jellyfin/manifest.json
 create mode 100644 homeassistant/components/jellyfin/media_source.py
 create mode 100644 homeassistant/components/jellyfin/strings.json
 create mode 100644 homeassistant/components/jellyfin/translations/en.json
 create mode 100644 tests/components/jellyfin/__init__.py
 create mode 100644 tests/components/jellyfin/const.py
 create mode 100644 tests/components/jellyfin/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 53012078f33..c891a73e290 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -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
diff --git a/.strict-typing b/.strict-typing
index 66a965b3221..01295668a64 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -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.*
diff --git a/CODEOWNERS b/CODEOWNERS
index ad69391ad29..a43b51d8b5b 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py
new file mode 100644
index 00000000000..a58108b05ab
--- /dev/null
+++ b/homeassistant/components/jellyfin/__init__.py
@@ -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
diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py
new file mode 100644
index 00000000000..9f6380e2181
--- /dev/null
+++ b/homeassistant/components/jellyfin/client_wrapper.py
@@ -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."""
diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py
new file mode 100644
index 00000000000..55de7c12e44
--- /dev/null
+++ b/homeassistant/components/jellyfin/config_flow.py
@@ -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
+        )
diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py
new file mode 100644
index 00000000000..d8379859e54
--- /dev/null
+++ b/homeassistant/components/jellyfin/const.py
@@ -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}"
diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json
new file mode 100644
index 00000000000..345cecc2eb6
--- /dev/null
+++ b/homeassistant/components/jellyfin/manifest.json
@@ -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"
+  ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py
new file mode 100644
index 00000000000..55e849a1f14
--- /dev/null
+++ b/homeassistant/components/jellyfin/media_source.py
@@ -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}")
diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json
new file mode 100644
index 00000000000..7587c1d86d9
--- /dev/null
+++ b/homeassistant/components/jellyfin/strings.json
@@ -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%]"
+    }
+  }
+}
\ No newline at end of file
diff --git a/homeassistant/components/jellyfin/translations/en.json b/homeassistant/components/jellyfin/translations/en.json
new file mode 100644
index 00000000000..05cdf6e9ea9
--- /dev/null
+++ b/homeassistant/components/jellyfin/translations/en.json
@@ -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"
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 4af6b656745..b620366a31b 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -147,6 +147,7 @@ FLOWS = [
     "islamic_prayer_times",
     "isy994",
     "izone",
+    "jellyfin",
     "juicenet",
     "keenetic_ndms2",
     "kmtronic",
diff --git a/mypy.ini b/mypy.ini
index 91bca58ba6b..f879ed5799b 100644
--- a/mypy.ini
+++ b/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
diff --git a/requirements_all.txt b/requirements_all.txt
index c800a9a9902..fd482b7ddda 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -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
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 86940d279dd..9421c4f3213 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
 
diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py
new file mode 100644
index 00000000000..e5ff9ab3207
--- /dev/null
+++ b/tests/components/jellyfin/__init__.py
@@ -0,0 +1 @@
+"""Tests for the jellyfin integration."""
diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py
new file mode 100644
index 00000000000..b33f00818b7
--- /dev/null
+++ b/tests/components/jellyfin/const.py
@@ -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"}
diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py
new file mode 100644
index 00000000000..cc23265e011
--- /dev/null
+++ b/tests/components/jellyfin/test_config_flow.py
@@ -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
-- 
GitLab