Play first item in m3u and pls playlists when casting (#70047)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Erik Montnemery 2022-04-26 22:16:37 +02:00 committed by GitHub
parent 0959ee4353
commit ce302f4540
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 484 additions and 5 deletions

View file

@ -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

View file

@ -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
)

View file

@ -0,0 +1,9 @@
[playlist]
NumberOfEntries=1
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,9 @@
[playlist]
NumberOfEntries=many
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,9 @@
[playlist]
NumberOfEntries=1
File1=http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,9 @@
[playlist]
NumberOfEntries=1
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=3

View file

@ -0,0 +1,8 @@
[playlist]
NumberOfEntries=1
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,7 @@
[playlist]
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,7 @@
NumberOfEntries=1
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1
Version=2

View file

@ -0,0 +1,7 @@
[playlist]
NumberOfEntries=1
File1=https://http-live.sr.se/p3-aac-192
Title1=Sveriges Radio
Length1=-1

View file

@ -0,0 +1,4 @@
#EXTM3U
#EXTINF:-1,Sveriges Radio
https://http-live.sr.se/p4norrbotten-mp3-192

View file

@ -0,0 +1,4 @@
#EXTM3U
#EXTINF:invalid
https://http-live.sr.se/p4norrbotten-mp3-192

View file

@ -0,0 +1,4 @@
#EXTM3U
#EXTINF:-1,Sveriges Radio
p4norrbotten-mp3-192

View file

@ -0,0 +1,3 @@
#EXTM3U
https://http-live.sr.se/p4norrbotten-mp3-192

View file

@ -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

View file

@ -0,0 +1 @@

View file

@ -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)

View file

@ -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"

View file

@ -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