diff --git a/.coveragerc b/.coveragerc
index 53012078f331d0b24c33636df7a82728e15a478f..c891a73e290e7d45dd5e57249cbba85d6ec07e95 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 66a965b3221f56d7b4b6c6dd992f101c803eb314..01295668a6438a18a2db56d035302ab0978c7f49 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 ad69391ad29540c6eb85ac955eda0a64ff6c1d8e..a43b51d8b5b4091a6404ced38e39f5d94a5bb1ff 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 0000000000000000000000000000000000000000..a58108b05abe8e40fd1f10d1f1a12c0075231a63
--- /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 0000000000000000000000000000000000000000..9f6380e218125491d44bf2a3df3788704d7df4bc
--- /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 0000000000000000000000000000000000000000..55de7c12e445d2ba44fe39c11b2507af3e75039b
--- /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 0000000000000000000000000000000000000000..d8379859e54c4991b98c52b72362691159c9c5fb
--- /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 0000000000000000000000000000000000000000..345cecc2eb66e61eb336717110c50923c6343376
--- /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 0000000000000000000000000000000000000000..55e849a1f1402ae4e06fa7d9c795d2165f90b599
--- /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 0000000000000000000000000000000000000000..7587c1d86d95688083f8b0503ea9d72dfe44e54e
--- /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 0000000000000000000000000000000000000000..05cdf6e9ea970bfcf00b2a94cc48e3ae8aa89b67
--- /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 4af6b65674539b24aec5f117caf3056cb5da3495..b620366a31b81e1146142794372de89a52efed9a 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 91bca58ba6b4cf37b5c47a2a569cdf44bebe5ec1..f879ed5799b01cd20cf9c463c4a28ca0f4485daa 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 c800a9a9902f9480e26ab1314bcc2061e932d8d9..fd482b7ddda00f1a882aa982b57013dad435188b 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 86940d279ddc98f16cf688b6e375b79f7ddff03f..9421c4f321300053ca57ff057e16dd271c4d9e2e 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 0000000000000000000000000000000000000000..e5ff9ab32073d15d8e95a414ef5783ba9375d3ea
--- /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 0000000000000000000000000000000000000000..b33f00818b733f056f6f2336e9dfd9652c3b1327
--- /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 0000000000000000000000000000000000000000..cc23265e0111e3923395d31c3ed3f518a497824c
--- /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