Some fixes for dlna based players (#800)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 29 Jul 2023 11:04:08 +0000 (13:04 +0200)
committerGitHub <noreply@github.com>
Sat, 29 Jul 2023 11:04:08 +0000 (13:04 +0200)
* some small fixes

* some finishing touches

* increase default icy interval

* adjust helper for chunksize

* rework didl lite generator

* allow run multicast scan for dlna and sonos

* some more fixes for dlna players

* fix small typo

music_assistant/server/controllers/streams.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/didl_lite.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/sonos/__init__.py

index a8d686194a473a325123262216efc4d7eeeee80d..989bb4aa37eb5279c7c1309bd897b612f203d1df 100644 (file)
@@ -60,6 +60,7 @@ DEFAULT_STREAM_HEADERS = {
     "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",  # noqa: E501
     "Cache-Control": "no-cache",
     "Connection": "close",
+    # "Accept-Ranges": "none",
     "icy-name": "Music Assistant",
     "icy-pub": "0",
 }
@@ -565,7 +566,7 @@ class StreamsController(CoreController):
         )
         # prepare request, add some DLNA/UPNP compatible headers
         enable_icy = request.headers.get("Icy-MetaData", "") == "1"
-        icy_meta_interval = 65536 if output_format.content_type.is_lossless() else 8192
+        icy_meta_interval = 16384 * 4 if output_format.content_type.is_lossless() else 16384
         headers = {
             **DEFAULT_STREAM_HEADERS,
             "Content-Type": f"audio/{output_format.output_format_str}",
@@ -637,11 +638,10 @@ class StreamsController(CoreController):
                     continue
 
                 # if icy metadata is enabled, send the icy metadata after the chunk
-                current_item = self.mass.player_queues.get_item(
-                    queue.queue_id, queue.index_in_buffer
-                )
                 if (
-                    current_item
+                    # use current item here and not buffered item, otherwise
+                    # the icy metadata will be too much ahead
+                    (current_item := queue.current_item)
                     and current_item.streamdetails
                     and current_item.streamdetails.stream_title
                 ):
@@ -651,6 +651,8 @@ class StreamsController(CoreController):
                 else:
                     title = "Music Assistant"
                 metadata = f"StreamTitle='{title}';".encode()
+                if current_item and current_item.image:
+                    metadata += f"StreamURL='{current_item.image.path}'".encode()
                 while len(metadata) % 16 != 0:
                     metadata += b"\x00"
                 length = len(metadata)
index 96c8d2506bb4d2c25052ea71419574ea6d62ff5b..56d6703088b7bd7d6a3dae4757d66235aa18a4ee 100644 (file)
@@ -716,22 +716,22 @@ async def get_silence(
 
 
 def get_chunksize(
-    content_type: ContentType,
-    sample_rate: int = 44100,
-    bit_depth: int = 16,
+    fmt: AudioFormat,
     seconds: int = 1,
 ) -> int:
     """Get a default chunksize for given contenttype."""
-    pcm_size = int(sample_rate * (bit_depth / 8) * 2 * seconds)
-    if content_type.is_pcm() or content_type == ContentType.WAV:
+    pcm_size = int(fmt.sample_rate * (fmt.bit_depth / 8) * 2 * seconds)
+    if fmt.content_type.is_pcm() or fmt.content_type == ContentType.WAV:
         return pcm_size
-    if content_type in (ContentType.WAV, ContentType.AIFF, ContentType.DSF):
+    if fmt.content_type in (ContentType.WAV, ContentType.AIFF, ContentType.DSF):
         return pcm_size
-    if content_type in (ContentType.FLAC, ContentType.WAVPACK, ContentType.ALAC):
-        return int(pcm_size * 0.6)
-    if content_type in (ContentType.MP3, ContentType.OGG, ContentType.M4A):
-        return int(640000 * seconds)
-    return 32000 * seconds
+    if fmt.content_type in (ContentType.FLAC, ContentType.WAVPACK, ContentType.ALAC):
+        return int(pcm_size * 0.5)
+    if fmt.content_type in (ContentType.MP3, ContentType.OGG):
+        return int((320000 / 8) * seconds)
+    if fmt.content_type in (ContentType.AAC, ContentType.M4A):
+        return int((256000 / 8) * seconds)
+    return int((320000 / 8) * seconds)
 
 
 async def _get_ffmpeg_args(
index 7a8f0edd650de1e4fe4ca550da2e02f1d9183539..f7cd3cdab220caf09ff50710cbb6a5ff3d035d14 100644 (file)
@@ -27,7 +27,7 @@ def create_didl_metadata(
             f"<upnp:albumArtURI>{escape_string(MASS_LOGO_ONLINE)}</upnp:albumArtURI>"
             "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
             f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+            f'<res duration="23:59:59.000" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
             "</item>"
             "</DIDL-Lite>"
         )
@@ -37,13 +37,13 @@ def create_didl_metadata(
         # radio or other non-track item
         return (
             '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
-            f'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
+            '<item id="1" parentID="0" restricted="1">'
             f"<dc:title>{escape_string(queue_item.name)}</dc:title>"
             f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
             f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
             "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
             f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+            f'<res duration="23:59:59.000" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
             "</item>"
             "</DIDL-Lite>"
         )
@@ -56,22 +56,21 @@ def create_didl_metadata(
         album = escape_string(queue_item.media_item.album.name)
     else:
         album = ""
-    item_class = "object.item.audioItem.musicTrack"
-    duration_str = str(datetime.timedelta(seconds=queue_item.duration))
+    duration_str = str(datetime.timedelta(seconds=queue_item.duration)) + ".000"
     return (
         '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
-        f'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
+        '<item id="1" parentID="0" restricted="1">'
         f"<dc:title>{title}</dc:title>"
         f"<dc:creator>{artist}</dc:creator>"
         f"<upnp:album>{album}</upnp:album>"
         f"<upnp:artist>{artist}</upnp:artist>"
-        f"<upnp:duration>{queue_item.duration}</upnp:duration>"
+        f"<upnp:duration>{int(queue_item.duration)}</upnp:duration>"
         "<upnp:playlistTitle>Music Assistant</upnp:playlistTitle>"
         f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
         f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
-        f"<upnp:class>{item_class}</upnp:class>"
+        "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
         f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-        f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+        f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
         "</item>"
         "</DIDL-Lite>"
     )
index d9fedf512edde9ad5c6edb42603d52f847976c0f..14da2528621670c88fbef125fc191d38470df5a3 100644 (file)
@@ -13,6 +13,7 @@ import time
 from collections.abc import Awaitable, Callable, Coroutine, Sequence
 from contextlib import suppress
 from dataclasses import dataclass, field
+from ipaddress import IPv4Address
 from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
 
 from async_upnp_client.aiohttp import AiohttpSessionRequester
@@ -130,6 +131,7 @@ class DLNAPlayer:
     supports_next_uri: bool | None = None
     end_of_track_reached: float | None = None
     last_command: float = field(default_factory=time.time)
+    need_elapsed_time_workaround: bool = False
 
     def update_attributes(self):
         """Update attributes of the MA Player from DLNA state."""
@@ -143,11 +145,13 @@ class DLNAPlayer:
             self.player.state = self.get_state(self.device)
             self.player.supported_features = self.get_supported_features(self.device)
             self.player.current_url = self.device.current_track_uri or ""
-            self.player.elapsed_time = float(self.device.media_position or 0)
-            if self.device.media_position_updated_at is not None:
-                self.player.elapsed_time_last_updated = (
-                    self.device.media_position_updated_at.timestamp()
-                )
+            if self.device.media_position:
+                # only update elapsed_time if the device actually reports it
+                self.player.elapsed_time = float(self.device.media_position)
+                if self.device.media_position_updated_at is not None:
+                    self.player.elapsed_time_last_updated = (
+                        self.device.media_position_updated_at.timestamp()
+                    )
             # some dlna players get stuck at the end of the track and won't
             # automatically play the next track, try to workaround that
             if (
@@ -290,7 +294,13 @@ class DLNAPlayerProvider(PlayerProvider):
         await dlna_player.device.async_set_transport_uri(url, title, didl_metadata)
         # Play it
         await dlna_player.device.async_wait_for_can_play(10)
+        # optimistically set this timestamp to help in case of a player
+        # that does not report the progress
+        now = time.time()
+        dlna_player.player.elapsed_time = 0
+        dlna_player.player.elapsed_time_last_updated = now
         await dlna_player.device.async_play()
+
         # force poll the device
         for sleep in (1, 2):
             await asyncio.sleep(sleep)
@@ -362,7 +372,7 @@ class DLNAPlayerProvider(PlayerProvider):
         finally:
             dlna_player.force_poll = False
 
-    async def _run_discovery(self) -> None:
+    async def _run_discovery(self, use_multicast: bool = False) -> None:
         """Discover DLNA players on the network."""
         if self._discovery_running:
             return
@@ -394,13 +404,17 @@ class DLNAPlayerProvider(PlayerProvider):
 
                 await self._device_discovered(ssdp_udn, discovery_info["location"])
 
-            await async_search(on_response)
+            # we iterate between using a regular and multicast search
+            if use_multicast:
+                await async_search(on_response, target=(str(IPv4Address("255.255.255.255")), 1900))
+            else:
+                await async_search(on_response)
 
         finally:
             self._discovery_running = False
 
         def reschedule():
-            self.mass.create_task(self._run_discovery())
+            self.mass.create_task(self._run_discovery(use_multicast=not use_multicast))
 
         # reschedule self once finished
         self.mass.loop.call_later(120, reschedule)
@@ -607,23 +621,12 @@ class DLNAPlayerProvider(PlayerProvider):
         # enqueue next item if needed
         if (
             dlna_player.player.state == PlayerState.PLAYING
+            and dlna_player.player.player_id in current_url
             and (not dlna_player.next_url or dlna_player.next_url == current_url)
             # prevent race conditions at start/stop by doing this check
-            and (time.time() - dlna_player.last_command) > 10
+            and (time.time() - dlna_player.last_command) > 4
         ):
             self.mass.create_task(self._enqueue_next_track(dlna_player))
-        # try to detect a player that gets stuck at the end of the track
-        if (
-            dlna_player.end_of_track_reached
-            and dlna_player.next_url
-            and dlna_player.supports_next_uri
-            and time.time() - dlna_player.end_of_track_reached > 10
-        ):
-            self.logger.warning(
-                "Detected that the player is stuck at the end of the track, "
-                "enabling workaround for this player."
-            )
-            dlna_player.supports_next_uri = False
         # if player does not support next uri, manual play it
         if (
             not dlna_player.supports_next_uri
index fa6f9cbc3f2753fc34fb609ed454b23dd9dc4f5a..292f85575d26cfd4ea835f35da38dcd7819b06c1 100644 (file)
@@ -370,8 +370,11 @@ class FileSystemProviderBase(MusicProvider):
                 file_path, self.instance_id
             ):
                 if library_item.media_type == MediaType.TRACK:
-                    album_ids.add(library_item.album.item_id)
-                    for artist in library_item.artists + library_item.album.artists:
+                    if library_item.album:
+                        album_ids.add(library_item.album.item_id)
+                        for artist in library_item.album.artists:
+                            artist_ids.add(artist.item_id)
+                    for artist in library_item.artists:
                         artist_ids.add(artist.item_id)
                 await controller.remove_item_from_library(library_item.item_id)
         # check if any albums need to be cleaned up
index 97da3d80763fab496bc6143ffc66e000cb26f043..200be689e9e259fd78acba8f63e2d5eecabb83a5 100644 (file)
@@ -78,7 +78,8 @@ class SonosPlayer:
     is_stereo_pair: bool = False
     next_url: str | None = None
     elapsed_time: int = 0
-    radio_mode_started: float | None = None
+    playback_started: float | None = None
+    need_elapsed_time_workaround: bool = False
 
     subscriptions: list[SubscriptionBase] = field(default_factory=list)
 
@@ -112,8 +113,13 @@ class SonosPlayer:
         # track info
         if update_track_info:
             self.track_info = self.soco.get_current_track_info()
+            # sonos reports bullshit elapsed time while playing radio (or flow mode),
+            # trying to be "smart" and resetting the counter when new ICY metadata is detected
+            # we try to detect this and work around it
+            self.need_elapsed_time_workaround = self.track_info["duration"] == "0:00:00"
+            if not self.need_elapsed_time_workaround:
+                self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0
             self.track_info_updated = time.time()
-            self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0
 
         # speaker info
         if update_speaker_info:
@@ -131,6 +137,7 @@ class SonosPlayer:
 
     def update_attributes(self):
         """Update attributes of the MA Player from soco.SoCo state."""
+        now = time.time()
         # generic attributes (speaker_info)
         self.player.name = self.speaker_info["zone_name"]
         self.player.volume_level = int(self.rendering_control_info["volume"])
@@ -138,20 +145,16 @@ class SonosPlayer:
 
         # transport info (playback state)
         current_transport_state = self.transport_info["current_transport_state"]
-        new_state = _convert_state(current_transport_state)
-        self.player.state = new_state
+        self.player.state = current_state = _convert_state(current_transport_state)
+
+        if self.playback_started is not None and current_state == PlayerState.IDLE:
+            self.playback_started = None
+        elif self.playback_started is None and current_state == PlayerState.PLAYING:
+            self.playback_started = now
 
         # media info (track info)
         self.player.current_url = self.track_info["uri"]
-
-        if self.radio_mode_started is not None:
-            # sonos reports bullshit elapsed time while playing radio,
-            # trying to be "smart" and resetting the counter when new ICY metadata is detected
-            if new_state == PlayerState.PLAYING:
-                now = time.time()
-                self.player.elapsed_time = int(now - self.radio_mode_started + 0.5)
-                self.player.elapsed_time_last_updated = now
-        else:
+        if not self.need_elapsed_time_workaround:
             self.player.elapsed_time = self.elapsed_time
             self.player.elapsed_time_last_updated = self.track_info_updated
 
@@ -208,8 +211,9 @@ class SonosPlayer:
 class SonosPlayerProvider(PlayerProvider):
     """Sonos Player provider."""
 
-    sonosplayers: dict[str, SonosPlayer]
-    _discovery_running: bool
+    sonosplayers: dict[str, SonosPlayer] | None = None
+    _discovery_running: bool = False
+    _discovery_reschedule_timer: asyncio.TimerHandle | None = None
 
     async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
@@ -222,9 +226,19 @@ class SonosPlayerProvider(PlayerProvider):
 
     async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
-        if hasattr(self, "sonosplayers"):
-            for player in self.sonosplayers.values():
-                player.soco.end_direct_control_session
+        if self._discovery_reschedule_timer:
+            self._discovery_reschedule_timer.cancel()
+            self._discovery_reschedule_timer = None
+        # await any in-progress discovery
+        while self._discovery_running:
+            await asyncio.sleep(0.5)
+        # cleanup players
+        if self.sonosplayers:
+            for player_id in list(self.sonosplayers):
+                player = self.sonosplayers.pop(player_id)
+                player.player.available = False
+                player.soco.end_direct_control_session()
+        self.sonosplayers = None
 
     def on_player_config_changed(
         self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
@@ -244,6 +258,7 @@ class SonosPlayerProvider(PlayerProvider):
             return
         await asyncio.to_thread(sonos_player.soco.stop)
         await asyncio.to_thread(sonos_player.soco.clear_queue)
+        sonos_player.playback_started = None
 
     async def cmd_play(self, player_id: str) -> None:
         """Send PLAY command to given player."""
@@ -287,14 +302,17 @@ class SonosPlayerProvider(PlayerProvider):
         if queue_item is None:
             # enforce mp3 radio mode for flow stream
             url = url.replace(".flac", ".mp3").replace(".wav", ".mp3")
-            sonos_player.radio_mode_started = time.time()
             await asyncio.to_thread(
                 sonos_player.soco.play_uri, url, title="Music Assistant", force_radio=True
             )
         else:
-            sonos_player.radio_mode_started = None
             await self._enqueue_item(sonos_player, url=url, queue_item=queue_item)
             await asyncio.to_thread(sonos_player.soco.play_from_queue, 0)
+        # optimistically set this timestamp to help figure out elapsed time later
+        now = time.time()
+        sonos_player.playback_started = now
+        sonos_player.player.elapsed_time = 0
+        sonos_player.player.elapsed_time_last_updated = now
 
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
@@ -305,6 +323,10 @@ class SonosPlayerProvider(PlayerProvider):
                 player_id,
             )
             return
+        if sonos_player.need_elapsed_time_workaround:
+            # no pause allowed when radio/flow mode is active
+            await self.cmd_stop()
+            return
         await asyncio.to_thread(sonos_player.soco.pause)
 
     async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
@@ -371,7 +393,7 @@ class SonosPlayerProvider(PlayerProvider):
         except ConnectionResetError as err:
             raise PlayerUnavailableError from err
 
-    async def _run_discovery(self) -> None:
+    async def _run_discovery(self, allow_network_scan=False) -> None:
         """Discover Sonos players on the network."""
         if self._discovery_running:
             return
@@ -379,7 +401,7 @@ class SonosPlayerProvider(PlayerProvider):
             self._discovery_running = True
             self.logger.debug("Sonos discovery started...")
             discovered_devices: set[soco.SoCo] = await asyncio.to_thread(
-                soco.discover, 120, allow_network_scan=True
+                soco.discover, allow_network_scan=allow_network_scan
             )
             if discovered_devices is None:
                 discovered_devices = set()
@@ -404,10 +426,11 @@ class SonosPlayerProvider(PlayerProvider):
             self._discovery_running = False
 
         def reschedule():
-            self.mass.create_task(self._run_discovery())
+            self._discovery_reschedule_timer = None
+            self.mass.create_task(self._run_discovery(allow_network_scan=not allow_network_scan))
 
         # reschedule self once finished
-        self.mass.loop.call_later(300, reschedule)
+        self._discovery_reschedule_timer = self.mass.loop.call_later(120, reschedule)
 
     async def _device_discovered(self, soco_device: soco.SoCo) -> None:
         """Handle discovered Sonos player."""
@@ -582,8 +605,8 @@ class SonosPlayerProvider(PlayerProvider):
 
         if signal_update:
             # send update to the player manager right away only if we are triggered from an event
-            # when we're just updating from a manual poll, the player manager will
-            # update will detect changes to the player object itself
+            # when we're just updating from a manual poll, the player manager
+            # will detect changes to the player object itself
             self.mass.players.update(sonos_player.player_id)
 
         # enqueue next item if needed