diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 06038aec5..444020612 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -7,6 +7,7 @@ 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 @@ -14,7 +15,12 @@ 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, @@ -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 @@ -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 diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index 81e19bdd3..b7f40ebdc 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -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, @@ -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) @@ -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, @@ -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, ) diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index e2d560583..ecf4bd763 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -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 @@ -228,21 +227,7 @@ 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, @@ -250,9 +235,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: 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}") diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index 05a569f49..32d56b7f1 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -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 @@ -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, @@ -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, )