Skip to content

Commit

Permalink
Fix playback of radio stations (#982)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Dec 31, 2023
1 parent 3ce475c commit 898f7e5
Show file tree
Hide file tree
Showing 4 changed files with 63 additions and 43 deletions.
49 changes: 47 additions & 2 deletions music_assistant/server/helpers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@
import re
import struct
from collections.abc import AsyncGenerator
from contextlib import suppress
from io import BytesIO
from time import time
from typing import TYPE_CHECKING

import aiofiles
from aiohttp import ClientTimeout

from music_assistant.common.models.errors import AudioError, MediaNotFoundError, MusicAssistantError
from music_assistant.common.models.errors import (
AudioError,
InvalidDataError,
MediaNotFoundError,
MusicAssistantError,
)
from music_assistant.common.models.media_items import (
AudioFormat,
ContentType,
Expand All @@ -26,6 +32,7 @@
CONF_VOLUME_NORMALIZATION_TARGET,
ROOT_LOGGER_NAME,
)
from music_assistant.server.helpers.playlists import fetch_playlist

from .process import AsyncProcess, check_output
from .util import create_tempfile
Expand Down Expand Up @@ -505,11 +512,49 @@ async def writer():
mass.create_task(analyze_audio(mass, streamdetails))


async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, bool]:
"""
Resolve a streaming radio URL.
Unwraps any playlists if needed.
Determines if the stream supports ICY metadata.
Returns unfolded URL and a bool if the URL supports ICY metadata.
"""
cache_key = f"resolved_radio_url_{url}"
if cache := await mass.cache.get(cache_key):
return cache
# handle playlisted radio urls
is_mpeg_dash = False
supports_icy = False
if ".m3u" in url or ".pls" in url:
# url is playlist, try to figure out how to handle it
with suppress(InvalidDataError, IndexError):
playlist = await fetch_playlist(mass, url)
if len(playlist) > 1 or ".m3u" in playlist[0] or ".pls" in playlist[0]:
# if it is an mpeg-dash stream, let ffmpeg handle that
is_mpeg_dash = True
url = playlist[0]
if not is_mpeg_dash:
# determine ICY metadata support by looking at the http headers
headers = {"Icy-MetaData": "1", "User-Agent": "VLC/3.0.2.LibVLC/3.0.2"}
timeout = ClientTimeout(total=0, connect=10, sock_read=5)
async with mass.http_session.head(
url, headers=headers, allow_redirects=True, timeout=timeout
) as resp:
headers = resp.headers
supports_icy = int(headers.get("icy-metaint", "0")) > 0

result = (url, supports_icy)
await mass.cache.set(cache_key, result)
return result


async def get_radio_stream(
mass: MusicAssistant, url: str, streamdetails: StreamDetails
) -> AsyncGenerator[bytes, None]:
"""Get radio audio stream from HTTP, including metadata retrieval."""
headers = {"Icy-MetaData": "1"}
headers = {"Icy-MetaData": "1", "User-Agent": "VLC/3.0.2.LibVLC/3.0.2"}
timeout = ClientTimeout(total=0, connect=30, sock_read=60)
async with mass.http_session.get(url, headers=headers, timeout=timeout) as resp:
headers = resp.headers
Expand Down
21 changes: 3 additions & 18 deletions music_assistant/server/providers/radiobrowser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
from music_assistant.common.models.enums import LinkType, ProviderFeature
from music_assistant.common.models.errors import InvalidDataError
from music_assistant.common.models.media_items import (
AudioFormat,
BrowseFolder,
Expand All @@ -24,8 +23,7 @@
SearchResults,
StreamDetails,
)
from music_assistant.server.helpers.audio import get_radio_stream
from music_assistant.server.helpers.playlists import fetch_playlist
from music_assistant.server.helpers.audio import get_radio_stream, resolve_radio_stream
from music_assistant.server.models.music_provider import MusicProvider

SUPPORTED_FEATURES = (ProviderFeature.SEARCH, ProviderFeature.BROWSE)
Expand Down Expand Up @@ -279,21 +277,8 @@ async def _parse_radio(self, radio_obj: dict) -> Radio:
async def get_stream_details(self, item_id: str) -> StreamDetails:
"""Get streamdetails for a radio station."""
stream = await self.radios.station(uuid=item_id)
url_resolved = stream.url_resolved
await self.radios.station_click(uuid=item_id)
direct = None
if ".m3u" in url_resolved or ".pls" in url_resolved:
# url is playlist, try to figure out how to handle it
# if it is an mpeg-dash stream, let ffmpeg handle that
try:
playlist = await fetch_playlist(self.mass, url_resolved)
if len(playlist) > 1 or ".m3u" in playlist[0] or ".pls" in playlist[0]:
direct = playlist[0]
elif playlist:
url_resolved = playlist[0]
except (InvalidDataError, IndexError):
# empty playlist ?!
direct = url_resolved
url_resolved, supports_icy = await resolve_radio_stream(self.mass, stream.url_resolved)
return StreamDetails(
provider=self.domain,
item_id=item_id,
Expand All @@ -302,7 +287,7 @@ async def get_stream_details(self, item_id: str) -> StreamDetails:
),
media_type=MediaType.RADIO,
data=url_resolved,
direct=direct,
direct=url_resolved if not supports_icy else None,
expires=time() + 24 * 3600,
)

Expand Down
23 changes: 4 additions & 19 deletions music_assistant/server/providers/tunein/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@
StreamDetails,
)
from music_assistant.constants import CONF_USERNAME
from music_assistant.server.helpers.audio import get_radio_stream
from music_assistant.server.helpers.playlists import fetch_playlist
from music_assistant.server.helpers.audio import get_radio_stream, resolve_radio_stream
from music_assistant.server.helpers.tags import parse_tags
from music_assistant.server.models.music_provider import MusicProvider

Expand Down Expand Up @@ -228,31 +227,17 @@ async def get_stream_details(self, item_id: str) -> StreamDetails:
if stream["media_type"] != media_type:
continue
# check if the radio stream is not a playlist
url = stream["url"]
direct = None
direct = None
if ".m3u" in url or ".pls" in url or stream.get("playlist_type"):
# url is playlist, try to figure out how to handle it
# if it is an mpeg-dash stream, let ffmpeg handle that
try:
playlist = await fetch_playlist(self.mass, url)
if len(playlist) > 1 or ".m3u" in playlist[0] or ".pls" in playlist[0]:
direct = playlist[0]
elif playlist:
url_resolved = playlist[0]
except (InvalidDataError, IndexError):
# empty playlist ?!
direct = url_resolved
url_resolved, supports_icy = await resolve_radio_stream(self.mass, stream["url"])
return StreamDetails(
provider=self.domain,
item_id=item_id,
audio_format=AudioFormat(
content_type=ContentType(stream["media_type"]),
),
media_type=MediaType.RADIO,
data=url,
data=url_resolved,
expires=time() + 24 * 3600,
direct=direct,
direct=url_resolved if not supports_icy else None,
)
raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}")

Expand Down
13 changes: 9 additions & 4 deletions music_assistant/server/providers/url/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
StreamDetails,
Track,
)
from music_assistant.server.helpers.audio import get_file_stream, get_http_stream, get_radio_stream
from music_assistant.server.helpers.audio import (
get_file_stream,
get_http_stream,
get_radio_stream,
resolve_radio_stream,
)
from music_assistant.server.helpers.playlists import fetch_playlist
from music_assistant.server.helpers.tags import AudioTags, parse_tags
from music_assistant.server.models.music_provider import MusicProvider
Expand Down Expand Up @@ -181,8 +186,8 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None:
"""Get streamdetails for a track/radio."""
item_id, url, media_info = await self._get_media_info(item_id)
is_radio = media_info.get("icy-name") or not media_info.duration
# we let ffmpeg handle with mpeg dash streams
mpeg_dash_stream = ".m3u" in url or ".pls" in url
if is_radio:
url, supports_icy = await resolve_radio_stream(self.mass, url)
return StreamDetails(
provider=self.instance_id,
item_id=item_id,
Expand All @@ -192,7 +197,7 @@ async def get_stream_details(self, item_id: str) -> StreamDetails | None:
bit_depth=media_info.bits_per_sample,
),
media_type=MediaType.RADIO if is_radio else MediaType.TRACK,
direct=None if is_radio and not mpeg_dash_stream else url,
direct=None if is_radio and supports_icy else url,
data=url,
)

Expand Down

0 comments on commit 898f7e5

Please sign in to comment.