diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 3c6ba2af43f..b90e9ab1726 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,13 +1,25 @@ """Helpers to deal with Cast devices.""" from __future__ import annotations +import asyncio +import configparser +from dataclasses import dataclass +import logging from typing import Optional +from urllib.parse import urlparse +import aiohttp import attr from pychromecast import dial from pychromecast.const import CAST_TYPE_GROUP from pychromecast.models import CastInfo +from homeassistant.helpers import aiohttp_client + +_LOGGER = logging.getLogger(__name__) + +_PLS_SECTION_PLAYLIST = "playlist" + @attr.s(slots=True, frozen=True) class ChromecastInfo: @@ -155,3 +167,143 @@ class CastStatusListener: else: self._mz_mgr.deregister_listener(self._uuid, self) self._valid = False + + +class PlaylistError(Exception): + """Exception wrapper for pls and m3u helpers.""" + + +class PlaylistSupported(PlaylistError): + """The playlist is supported by cast devices and should not be parsed.""" + + +@dataclass +class PlaylistItem: + """Playlist item.""" + + length: str | None + title: str | None + url: str + + +def _is_url(url): + """Validate the URL can be parsed and at least has scheme + netloc.""" + result = urlparse(url) + return all([result.scheme, result.netloc]) + + +async def _fetch_playlist(hass, url): + """Fetch a playlist from the given url.""" + try: + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + async with session.get(url, timeout=5) as resp: + charset = resp.charset or "utf-8" + try: + playlist_data = (await resp.content.read(64 * 1024)).decode(charset) + except ValueError as err: + raise PlaylistError(f"Could not decode playlist {url}") from err + except asyncio.TimeoutError as err: + raise PlaylistError(f"Timeout while fetching playlist {url}") from err + except aiohttp.client_exceptions.ClientError as err: + raise PlaylistError(f"Error while fetching playlist {url}") from err + + return playlist_data + + +async def parse_m3u(hass, url): + """Very simple m3u parser. + + Based on https://github.com/dvndrsn/M3uParser/blob/master/m3uparser.py + """ + m3u_data = await _fetch_playlist(hass, url) + m3u_lines = m3u_data.splitlines() + + playlist = [] + + length = None + title = None + + for line in m3u_lines: + line = line.strip() + if line.startswith("#EXTINF:"): + # Get length and title from #EXTINF line + info = line.split("#EXTINF:")[1].split(",", 1) + if len(info) != 2: + _LOGGER.warning("Ignoring invalid extinf %s in playlist %s", line, url) + continue + length = info[0].split(" ", 1) + title = info[1].strip() + elif line.startswith("#EXT-X-VERSION:"): + # HLS stream, supported by cast devices + raise PlaylistSupported("HLS") + elif line.startswith("#"): + # Ignore other extensions + continue + elif len(line) != 0: + # Get song path from all other, non-blank lines + if not _is_url(line): + raise PlaylistError(f"Invalid item {line} in playlist {url}") + playlist.append(PlaylistItem(length=length, title=title, url=line)) + # reset the song variables so it doesn't use the same EXTINF more than once + length = None + title = None + + return playlist + + +async def parse_pls(hass, url): + """Very simple pls parser. + + Based on https://github.com/mariob/plsparser/blob/master/src/plsparser.py + """ + pls_data = await _fetch_playlist(hass, url) + + pls_parser = configparser.ConfigParser() + try: + pls_parser.read_string(pls_data, url) + except configparser.Error as err: + raise PlaylistError(f"Can't parse playlist {url}") from err + + if ( + _PLS_SECTION_PLAYLIST not in pls_parser + or pls_parser[_PLS_SECTION_PLAYLIST].getint("Version") != 2 + ): + raise PlaylistError(f"Invalid playlist {url}") + + try: + num_entries = pls_parser.getint(_PLS_SECTION_PLAYLIST, "NumberOfEntries") + except (configparser.NoOptionError, ValueError) as err: + raise PlaylistError(f"Invalid NumberOfEntries in playlist {url}") from err + + playlist_section = pls_parser[_PLS_SECTION_PLAYLIST] + + playlist = [] + for entry in range(1, num_entries + 1): + file_option = f"File{entry}" + if file_option not in playlist_section: + _LOGGER.warning("Missing %s in pls from %s", file_option, url) + continue + item_url = playlist_section[file_option] + if not _is_url(item_url): + raise PlaylistError(f"Invalid item {item_url} in playlist {url}") + playlist.append( + PlaylistItem( + length=playlist_section.get(f"Length{entry}"), + title=playlist_section.get(f"Title{entry}"), + url=item_url, + ) + ) + return playlist + + +async def parse_playlist(hass, url): + """Parse an m3u or pls playlist.""" + if url.endswith(".m3u") or url.endswith(".m3u8"): + playlist = await parse_m3u(hass, url) + else: + playlist = await parse_pls(hass, url) + + if not playlist: + raise PlaylistError(f"Empty playlist {url}") + + return playlist diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 7f9f2fdfb66..e5b2699c2da 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -64,7 +64,14 @@ from .const import ( SIGNAL_HASS_CAST_SHOW_VIEW, ) from .discovery import setup_internal_discovery -from .helpers import CastStatusListener, ChromecastInfo, ChromeCastZeroconf +from .helpers import ( + CastStatusListener, + ChromecastInfo, + ChromeCastZeroconf, + PlaylistError, + PlaylistSupported, + parse_playlist, +) _LOGGER = logging.getLogger(__name__) @@ -582,14 +589,13 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): media_id = sourced_media.url extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) - metadata = extra.get("metadata") # Handle media supported by a known cast app if media_type == CAST_DOMAIN: try: app_data = json.loads(media_id) - if metadata is not None: - app_data["metadata"] = extra.get("metadata") + if metadata := extra.get("metadata"): + app_data["metadata"] = metadata except json.JSONDecodeError: _LOGGER.error("Invalid JSON in media_content_id") raise @@ -640,9 +646,51 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): "hlsVideoSegmentFormat": "fmp4", }, } + elif ( + media_id.endswith(".m3u") + or media_id.endswith(".m3u8") + or media_id.endswith(".pls") + ): + try: + playlist = await parse_playlist(self.hass, media_id) + _LOGGER.debug( + "[%s %s] Playing item %s from playlist %s", + self.entity_id, + self._cast_info.friendly_name, + playlist[0].url, + media_id, + ) + media_id = playlist[0].url + if title := playlist[0].title: + extra = { + **extra, + "metadata": {"title": title}, + } + except PlaylistSupported as err: + _LOGGER.debug( + "[%s %s] Playlist %s is supported: %s", + self.entity_id, + self._cast_info.friendly_name, + media_id, + err, + ) + except PlaylistError as err: + _LOGGER.warning( + "[%s %s] Failed to parse playlist %s: %s", + self.entity_id, + self._cast_info.friendly_name, + media_id, + err, + ) # Default to play with the default media receiver app_data = {"media_id": media_id, "media_type": media_type, **extra} + _LOGGER.debug( + "[%s %s] Playing %s with default_media_receiver", + self.entity_id, + self._cast_info.friendly_name, + app_data, + ) await self.hass.async_add_executor_job( quick_play, self._chromecast, "default_media_receiver", app_data ) diff --git a/tests/components/cast/fixtures/164-hi-aac.pls b/tests/components/cast/fixtures/164-hi-aac.pls new file mode 100644 index 00000000000..8c29976b192 --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac.pls @@ -0,0 +1,9 @@ +[playlist] + +NumberOfEntries=1 + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_invalid.pls b/tests/components/cast/fixtures/164-hi-aac_invalid.pls new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/cast/fixtures/164-hi-aac_invalid_entries.pls b/tests/components/cast/fixtures/164-hi-aac_invalid_entries.pls new file mode 100644 index 00000000000..5bbe3f45b0e --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_invalid_entries.pls @@ -0,0 +1,9 @@ +[playlist] + +NumberOfEntries=many + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_invalid_file.pls b/tests/components/cast/fixtures/164-hi-aac_invalid_file.pls new file mode 100644 index 00000000000..9a9913ea0dd --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_invalid_file.pls @@ -0,0 +1,9 @@ +[playlist] + +NumberOfEntries=1 + +File1=http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_invalid_version.pls b/tests/components/cast/fixtures/164-hi-aac_invalid_version.pls new file mode 100644 index 00000000000..4ec26ad5175 --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_invalid_version.pls @@ -0,0 +1,9 @@ +[playlist] + +NumberOfEntries=1 + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=3 diff --git a/tests/components/cast/fixtures/164-hi-aac_missing_file.pls b/tests/components/cast/fixtures/164-hi-aac_missing_file.pls new file mode 100644 index 00000000000..8f0eafa4163 --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_missing_file.pls @@ -0,0 +1,8 @@ +[playlist] + +NumberOfEntries=1 + +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_no_entries.pls b/tests/components/cast/fixtures/164-hi-aac_no_entries.pls new file mode 100644 index 00000000000..cff9bd4b84e --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_no_entries.pls @@ -0,0 +1,7 @@ +[playlist] + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_no_playlist.pls b/tests/components/cast/fixtures/164-hi-aac_no_playlist.pls new file mode 100644 index 00000000000..cc013b64010 --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_no_playlist.pls @@ -0,0 +1,7 @@ +NumberOfEntries=1 + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 + +Version=2 diff --git a/tests/components/cast/fixtures/164-hi-aac_no_version.pls b/tests/components/cast/fixtures/164-hi-aac_no_version.pls new file mode 100644 index 00000000000..eb9a71c31ff --- /dev/null +++ b/tests/components/cast/fixtures/164-hi-aac_no_version.pls @@ -0,0 +1,7 @@ +[playlist] + +NumberOfEntries=1 + +File1=https://http-live.sr.se/p3-aac-192 +Title1=Sveriges Radio +Length1=-1 diff --git a/tests/components/cast/fixtures/209-hi-mp3.m3u b/tests/components/cast/fixtures/209-hi-mp3.m3u new file mode 100644 index 00000000000..0efb19e5664 --- /dev/null +++ b/tests/components/cast/fixtures/209-hi-mp3.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTINF:-1,Sveriges Radio +https://http-live.sr.se/p4norrbotten-mp3-192 diff --git a/tests/components/cast/fixtures/209-hi-mp3_bad_extinf.m3u b/tests/components/cast/fixtures/209-hi-mp3_bad_extinf.m3u new file mode 100644 index 00000000000..5e4bc5d9711 --- /dev/null +++ b/tests/components/cast/fixtures/209-hi-mp3_bad_extinf.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTINF:invalid +https://http-live.sr.se/p4norrbotten-mp3-192 diff --git a/tests/components/cast/fixtures/209-hi-mp3_bad_url.m3u b/tests/components/cast/fixtures/209-hi-mp3_bad_url.m3u new file mode 100644 index 00000000000..4e7f25c974e --- /dev/null +++ b/tests/components/cast/fixtures/209-hi-mp3_bad_url.m3u @@ -0,0 +1,4 @@ +#EXTM3U + +#EXTINF:-1,Sveriges Radio +p4norrbotten-mp3-192 diff --git a/tests/components/cast/fixtures/209-hi-mp3_no_extinf.m3u b/tests/components/cast/fixtures/209-hi-mp3_no_extinf.m3u new file mode 100644 index 00000000000..b795a98422f --- /dev/null +++ b/tests/components/cast/fixtures/209-hi-mp3_no_extinf.m3u @@ -0,0 +1,3 @@ +#EXTM3U + +https://http-live.sr.se/p4norrbotten-mp3-192 diff --git a/tests/components/cast/fixtures/bbc_radio_fourfm.m3u8 b/tests/components/cast/fixtures/bbc_radio_fourfm.m3u8 new file mode 100644 index 00000000000..1dc1e1927d4 --- /dev/null +++ b/tests/components/cast/fixtures/bbc_radio_fourfm.m3u8 @@ -0,0 +1,4 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=101760,CODECS="mp4a.40.5" +http://as-hls-ww-live.akamaized.net/pool_904/live/ww/bbc_radio_fourfm/bbc_radio_fourfm.isml/bbc_radio_fourfm-audio%3d96000.norewind.m3u8 diff --git a/tests/components/cast/fixtures/empty.m3u b/tests/components/cast/fixtures/empty.m3u new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/components/cast/fixtures/empty.m3u @@ -0,0 +1 @@ + diff --git a/tests/components/cast/test_helpers.py b/tests/components/cast/test_helpers.py new file mode 100644 index 00000000000..0d7a3b1ff14 --- /dev/null +++ b/tests/components/cast/test_helpers.py @@ -0,0 +1,114 @@ +"""Tests for the Cast integration helpers.""" +import asyncio + +from aiohttp import client_exceptions +import pytest + +from homeassistant.components.cast.helpers import ( + PlaylistError, + PlaylistItem, + PlaylistSupported, + parse_playlist, +) + +from tests.common import load_fixture + + +async def test_hls_playlist_supported(hass, aioclient_mock): + """Test playlist parsing of HLS playlist.""" + url = "http://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_radio_fourfm.m3u8" + aioclient_mock.get(url, text=load_fixture("bbc_radio_fourfm.m3u8", "cast")) + with pytest.raises(PlaylistSupported): + await parse_playlist(hass, url) + + +@pytest.mark.parametrize( + "url,fixture,expected_playlist", + ( + ( + "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", + "209-hi-mp3.m3u", + [ + PlaylistItem( + length=["-1"], + title="Sveriges Radio", + url="https://http-live.sr.se/p4norrbotten-mp3-192", + ) + ], + ), + ( + "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", + "209-hi-mp3_bad_extinf.m3u", + [ + PlaylistItem( + length=None, + title=None, + url="https://http-live.sr.se/p4norrbotten-mp3-192", + ) + ], + ), + ( + "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", + "209-hi-mp3_no_extinf.m3u", + [ + PlaylistItem( + length=None, + title=None, + url="https://http-live.sr.se/p4norrbotten-mp3-192", + ) + ], + ), + ( + "http://sverigesradio.se/topsy/direkt/164-hi-aac.pls", + "164-hi-aac.pls", + [ + PlaylistItem( + length="-1", + title="Sveriges Radio", + url="https://http-live.sr.se/p3-aac-192", + ) + ], + ), + ), +) +async def test_parse_playlist(hass, aioclient_mock, url, fixture, expected_playlist): + """Test playlist parsing of HLS playlist.""" + aioclient_mock.get(url, text=load_fixture(fixture, "cast")) + playlist = await parse_playlist(hass, url) + assert expected_playlist == playlist + + +@pytest.mark.parametrize( + "url,fixture", + ( + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_invalid_entries.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_invalid_file.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_invalid_version.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_invalid.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_missing_file.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_no_entries.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_no_playlist.pls"), + ("http://sverigesradio.se/164-hi-aac.pls", "164-hi-aac_no_version.pls"), + ("https://sverigesradio.se/209-hi-mp3.m3u", "209-hi-mp3_bad_url.m3u"), + ("https://sverigesradio.se/209-hi-mp3.m3u", "empty.m3u"), + ), +) +async def test_parse_bad_playlist(hass, aioclient_mock, url, fixture): + """Test playlist parsing of HLS playlist.""" + aioclient_mock.get(url, text=load_fixture(fixture, "cast")) + with pytest.raises(PlaylistError): + await parse_playlist(hass, url) + + +@pytest.mark.parametrize( + "url,exc", + ( + ("http://sverigesradio.se/164-hi-aac.pls", asyncio.TimeoutError), + ("http://sverigesradio.se/164-hi-aac.pls", client_exceptions.ClientError), + ), +) +async def test_parse_http_error(hass, aioclient_mock, url, exc): + """Test playlist parsing of HLS playlist when aioclient raises.""" + aioclient_mock.get(url, text="", exc=exc) + with pytest.raises(PlaylistError): + await parse_playlist(hass, url) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 6fa0d79c657..063490e6e13 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -44,7 +44,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er, from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, assert_setup_component, mock_platform +from tests.common import ( + MockConfigEntry, + assert_setup_component, + load_fixture, + mock_platform, +) from tests.components.media_player import common # pylint: disable=invalid-name @@ -1108,6 +1113,80 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistant, quick_play_mock): ) +@pytest.mark.parametrize( + "url,fixture,playlist_item", + ( + # Test title is extracted from m3u playlist + ( + "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", + "209-hi-mp3.m3u", + { + "media_id": "https://http-live.sr.se/p4norrbotten-mp3-192", + "media_type": "audio", + "metadata": {"title": "Sveriges Radio"}, + }, + ), + # Test title is extracted from pls playlist + ( + "http://sverigesradio.se/topsy/direkt/164-hi-aac.pls", + "164-hi-aac.pls", + { + "media_id": "https://http-live.sr.se/p3-aac-192", + "media_type": "audio", + "metadata": {"title": "Sveriges Radio"}, + }, + ), + # Test HLS playlist is forwarded to the device + ( + "http://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_radio_fourfm.m3u8", + "bbc_radio_fourfm.m3u8", + { + "media_id": "http://a.files.bbci.co.uk/media/live/manifesto/audio/simulcast/hls/nonuk/sbr_low/ak/bbc_radio_fourfm.m3u8", + "media_type": "audio", + }, + ), + # Test bad playlist is forwarded to the device + ( + "https://sverigesradio.se/209-hi-mp3.m3u", + "209-hi-mp3_bad_url.m3u", + { + "media_id": "https://sverigesradio.se/209-hi-mp3.m3u", + "media_type": "audio", + }, + ), + ), +) +async def test_entity_play_media_playlist( + hass: HomeAssistant, aioclient_mock, quick_play_mock, url, fixture, playlist_item +): + """Test playing media.""" + entity_id = "media_player.speaker" + aioclient_mock.get(url, text=load_fixture(fixture, "cast")) + + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.com:8123"}, + ) + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + _, conn_status_cb, _ = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + # Play_media + await common.async_play_media(hass, "audio", url, entity_id) + quick_play_mock.assert_called_once_with( + chromecast, + "default_media_receiver", + playlist_item, + ) + + async def test_entity_media_content_type(hass: HomeAssistant): """Test various content types.""" entity_id = "media_player.speaker" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 6868ff1f71d..d69c6d7c290 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -175,6 +175,7 @@ class AiohttpClientMockResponse: if response is None: response = b"" + self.charset = "utf-8" self.method = method self._url = url self.status = status