Fix playback of radio stations (#982)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 31 Dec 2023 00:20:07 +0000 (01:20 +0100)
committerGitHub <noreply@github.com>
Sun, 31 Dec 2023 00:20:07 +0000 (01:20 +0100)
music_assistant/server/helpers/audio.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/url/__init__.py

index 06038aec58373daebce563e742002682e8b5b37b..4440206127cbccffbfa8609dd82f137b86f907e5 100644 (file)
@@ -7,6 +7,7 @@ import os
 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 @@ 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,
@@ -26,6 +32,7 @@ from music_assistant.constants import (
     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 get_media_stream(  # noqa: PLR0915
                 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
index 81e19bdd39e221e8ce7d687667af45298aa6fa4d..b7f40ebdce87fc4c1c1356d5e5e46bdba15bbe0b 100644 (file)
@@ -9,7 +9,6 @@ from radios import FilterBy, Order, RadioBrowser, RadioBrowserError
 
 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 @@ from music_assistant.common.models.media_items import (
     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 @@ class RadioBrowserProvider(MusicProvider):
     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 @@ class RadioBrowserProvider(MusicProvider):
             ),
             media_type=MediaType.RADIO,
             data=url_resolved,
-            direct=direct,
+            direct=url_resolved if not supports_icy else None,
             expires=time() + 24 * 3600,
         )
 
index e2d560583145e692da637b9384c4c54efef21312..ecf4bd763254dd9bf330ef249ca6b66d84e7b59d 100644 (file)
@@ -22,8 +22,7 @@ from music_assistant.common.models.media_items import (
     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 @@ class TuneInProvider(MusicProvider):
             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 @@ class TuneInProvider(MusicProvider):
                     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}")
 
index 05a569f4934b33887840be92aa72a7e20f87e4f8..32d56b7f1317d05b59a2f96dd230aa406c42953c 100644 (file)
@@ -17,7 +17,12 @@ from music_assistant.common.models.media_items import (
     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 @@ class URLProvider(MusicProvider):
         """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 @@ class URLProvider(MusicProvider):
                 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,
         )