From d247c92ceaf421d2dfbd47cf3d0cfd16b5808196 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 14 Jul 2022 23:29:00 +0200 Subject: [PATCH] Fix for radio streams with playlist url (#416) * Handle radio stream playlists * playlist parsing for tunein and url provider --- music_assistant/helpers/playlists.py | 74 +++++++++++++++++++++++ music_assistant/music_providers/tunein.py | 26 +++++--- music_assistant/music_providers/url.py | 16 ++++- 3 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 music_assistant/helpers/playlists.py diff --git a/music_assistant/helpers/playlists.py b/music_assistant/helpers/playlists.py new file mode 100644 index 00000000..9439cf04 --- /dev/null +++ b/music_assistant/helpers/playlists.py @@ -0,0 +1,74 @@ +"""Helpers for parsing playlists.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, List + +import aiohttp + +from music_assistant.models.errors import InvalidDataError + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +LOGGER = logging.getLogger(__name__) + + +async def parse_m3u(m3u_data: str) -> List[str]: + """Parse (only) filenames/urls from m3u playlist file.""" + m3u_lines = m3u_data.splitlines() + lines = [] + for line in m3u_lines: + line = line.strip() + if line.startswith("#"): + # ignore metadata + continue + if len(line) != 0: + # Get uri/path from all other, non-blank lines + lines.append(line) + + return lines + + +async def parse_pls(pls_data: str) -> List[str]: + """Parse (only) filenames/urls from pls playlist file.""" + pls_lines = pls_data.splitlines() + lines = [] + for line in pls_lines: + line = line.strip() + if not line.startswith("File"): + # ignore metadata lines + continue + if "=" in line: + # Get uri/path from all other, non-blank lines + lines.append(line.split("=")[1]) + + return lines + + +async def fetch_playlist(mass: MusicAssistant, url: str) -> List[str]: + """Parse an online m3u or pls playlist.""" + + try: + async with mass.http_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 InvalidDataError(f"Could not decode playlist {url}") from err + except asyncio.TimeoutError as err: + raise InvalidDataError(f"Timeout while fetching playlist {url}") from err + except aiohttp.client_exceptions.ClientError as err: + raise InvalidDataError(f"Error while fetching playlist {url}") from err + + if url.endswith(".m3u") or url.endswith(".m3u8"): + playlist = await parse_m3u(playlist_data) + else: + playlist = await parse_pls(playlist_data) + + if not playlist: + raise InvalidDataError(f"Empty playlist {url}") + + return playlist diff --git a/music_assistant/music_providers/tunein.py b/music_assistant/music_providers/tunein.py index f55b08b0..b28affe5 100644 --- a/music_assistant/music_providers/tunein.py +++ b/music_assistant/music_providers/tunein.py @@ -1,11 +1,13 @@ """Tune-In musicprovider support for MusicAssistant.""" from __future__ import annotations +from time import time from typing import AsyncGenerator, List, Optional from asyncio_throttle import Throttler from music_assistant.helpers.audio import get_radio_stream +from music_assistant.helpers.playlists import fetch_playlist from music_assistant.helpers.util import create_sort_name from music_assistant.models.enums import ProviderType from music_assistant.models.errors import LoginFailed, MediaNotFoundError @@ -183,14 +185,22 @@ class TuneInProvider(MusicProvider): item_id, media_type = item_id.split("--", 1) stream_info = await self.__get_data("Tune.ashx", id=item_id) for stream in stream_info["body"]: - if stream["media_type"] == media_type: - return StreamDetails( - provider=self.type, - item_id=item_id, - content_type=ContentType(stream["media_type"]), - media_type=MediaType.RADIO, - data=stream["url"], - ) + + if stream["media_type"] != media_type: + continue + # check if the radio stream is not a playlist + url = stream["url"] + if url.endswith("m3u8") or url.endswith("m3u") or url.endswith("pls"): + playlist = await fetch_playlist(self.mass, url) + url = playlist[0] + return StreamDetails( + provider=self.type, + item_id=item_id, + content_type=ContentType(stream["media_type"]), + media_type=MediaType.RADIO, + data=url, + expires=time() + 24 * 3600, + ) raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}") async def get_audio_stream( diff --git a/music_assistant/music_providers/url.py b/music_assistant/music_providers/url.py index 868b6a4f..049eadba 100644 --- a/music_assistant/music_providers/url.py +++ b/music_assistant/music_providers/url.py @@ -9,6 +9,7 @@ from music_assistant.helpers.audio import ( get_http_stream, get_radio_stream, ) +from music_assistant.helpers.playlists import fetch_playlist from music_assistant.helpers.tags import AudioTags, parse_tags from music_assistant.models.config import MusicProviderConfig from music_assistant.models.enums import ( @@ -123,7 +124,17 @@ class URLProvider(MusicProvider): self, item_id_or_url: str, force_refresh: bool = False ) -> Tuple[str, str, AudioTags]: """Retrieve (cached) mediainfo for url.""" - if "?" in item_id_or_url or "&" in item_id_or_url: + # check if the radio stream is not a playlist + if ( + item_id_or_url.endswith("m3u8") + or item_id_or_url.endswith("m3u") + or item_id_or_url.endswith("pls") + ): + playlist = await fetch_playlist(self.mass, item_id_or_url) + url = playlist[0] + item_id = item_id_or_url + self._full_url[item_id] = url + elif "?" in item_id_or_url or "&" in item_id_or_url: # store the 'real' full url to be picked up later # this makes sure that we're not storing any temporary data like auth keys etc # a request for an url mediaitem always passes here first before streamdetails @@ -162,6 +173,7 @@ class URLProvider(MusicProvider): sample_rate=media_info.sample_rate, bit_depth=media_info.bits_per_sample, direct=None if is_radio else url, + data=url, ) async def get_audio_stream( @@ -181,7 +193,7 @@ class URLProvider(MusicProvider): ): yield chunk else: - # regular stream url (without icy meta and reconnect) + # regular stream url (without icy meta) async for chunk in get_http_stream( self.mass, streamdetails.data, streamdetails, seek_position ): -- 2.34.1