From 1595f6a1682062892c548ae3dcd4ef3280915d67 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 17 Sep 2020 02:02:16 +0200 Subject: [PATCH] adjustments for hass integration --- music_assistant/constants.py | 5 +- music_assistant/http_streamer.py | 11 +- music_assistant/models/player.py | 3 +- music_assistant/models/player_queue.py | 47 ++--- music_assistant/music_manager.py | 53 +++--- music_assistant/player_manager.py | 36 ++-- .../providers/chromecast/player.py | 2 - .../providers/demo/demo_playerprovider.py | 7 +- .../providers/home_assistant/__init__.py | 180 +----------------- music_assistant/providers/qobuz/__init__.py | 6 +- 10 files changed, 94 insertions(+), 256 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 00f9fbdd..67a72946 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,6 +1,6 @@ """All constants for Music Assistant.""" -__version__ = "0.0.30" +__version__ = "0.0.31" REQUIRED_PYTHON_VER = "3.7" CONF_USERNAME = "username" @@ -25,11 +25,10 @@ EVENT_PLAYER_CHANGED = "player changed" EVENT_STREAM_STARTED = "streaming started" EVENT_STREAM_ENDED = "streaming ended" EVENT_CONFIG_CHANGED = "config changed" -EVENT_PLAYBACK_STARTED = "playback started" -EVENT_PLAYBACK_STOPPED = "playback stopped" EVENT_MUSIC_SYNC_STATUS = "music sync status" EVENT_QUEUE_UPDATED = "queue updated" EVENT_QUEUE_ITEMS_UPDATED = "queue items updated" +EVENT_QUEUE_TIME_UPDATED = "queue time updated" EVENT_SHUTDOWN = "application shutdown" EVENT_PROVIDER_REGISTERED = "provider registered" EVENT_PLAYER_CONTROL_REGISTERED = "player control registered" diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index fe8d2d94..e972fbb6 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -441,6 +441,7 @@ class HTTPStreamer: yield (True, b"") return # fire event that streaming has started for this track + streamdetails.path = "" # invalidate self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly @@ -462,10 +463,12 @@ class HTTPStreamer: yield (False, prev_chunk) prev_chunk = chunk # fire event that streaming has ended - self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails) - # send task to background to analyse the audio - if queue_item.media_type == MediaType.Track: - self.mass.loop.run_in_executor(None, self.__analyze_audio, streamdetails) + if not cancelled.is_set(): + streamdetails.seconds_played = queue_item.duration + self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails) + # send task to background to analyse the audio + if queue_item.media_type == MediaType.Track: + self.mass.add_job(self.__analyze_audio, streamdetails) def __get_player_sox_options( self, player_id: str, streamdetails: StreamDetails diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 15999a3f..63aa45fa 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -13,7 +13,6 @@ from music_assistant.utils import CustomIntEnum class PlayerState(Enum): """Enum for the playstate of a player.""" - Off = "off" Stopped = "stopped" Paused = "paused" Playing = "playing" @@ -45,7 +44,7 @@ class Player(DataClassDictMixin): name: str = "" powered: bool = False elapsed_time: int = 0 - state: PlayerState = PlayerState.Off + state: PlayerState = PlayerState.Stopped available: bool = True current_uri: str = "" volume_level: int = 0 diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 73bb5532..5bfbb2e3 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -8,9 +8,8 @@ from enum import Enum from typing import List from music_assistant.constants import ( - EVENT_PLAYBACK_STARTED, - EVENT_PLAYBACK_STOPPED, EVENT_QUEUE_ITEMS_UPDATED, + EVENT_QUEUE_TIME_UPDATED, EVENT_QUEUE_UPDATED, ) from music_assistant.models.media_types import Track @@ -67,7 +66,6 @@ class PlayerQueue: self._last_item_time = 0 self._last_queue_startindex = 0 self._next_queue_startindex = 0 - self._last_player_state = PlayerState.Stopped self._last_track = None # load previous queue settings from disk self.mass.add_job(self.__async_restore_saved_state()) @@ -237,6 +235,7 @@ class PlayerQueue: return if self.use_queue_stream: return await self.async_play_index(self.cur_index + 1) + await self.mass.player_manager.async_cmd_power_on(self.player_id) return await self.mass.player_manager.get_player_provider( self.player_id ).async_cmd_next(self.player_id) @@ -245,6 +244,7 @@ class PlayerQueue: """Play the previous track in the queue.""" if self.cur_index is None: return + await self.mass.player_manager.async_cmd_power_on(self.player_id) if self.use_queue_stream: return await self.async_play_index(self.cur_index - 1) return await self.mass.player_manager.async_cmd_previous(self.player_id) @@ -252,6 +252,7 @@ class PlayerQueue: async def async_resume(self): """Resume previous queue.""" if self.items: + await self.mass.player_manager.async_cmd_power_on(self.player_id) prev_index = self.cur_index supports_queue = PlayerFeature.QUEUE in self.player.features if self.use_queue_stream or not supports_queue: @@ -271,6 +272,7 @@ class PlayerQueue: async def async_play_index(self, index): """Play item at index X in queue.""" + await self.mass.player_manager.async_cmd_power_on(self.player_id) player_prov = self.mass.player_manager.get_player_provider(self.player_id) supports_queue = PlayerFeature.QUEUE in self.player.features if not isinstance(index, int): @@ -329,6 +331,7 @@ class PlayerQueue: async def async_load(self, queue_items: List[QueueItem]): """Load (overwrite) queue with new items.""" + await self.mass.player_manager.async_cmd_power_on(self.player_id) supports_queue = PlayerFeature.QUEUE in self.player.features for index, item in enumerate(queue_items): item.sort_index = index @@ -470,7 +473,6 @@ class PlayerQueue: break # process new index await self.async_process_queue_update(cur_index, track_time) - self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict()) async def async_start_queue_stream(self): """Call when queue_streamer starts playing the queue stream.""" @@ -521,34 +523,21 @@ class PlayerQueue: async def async_process_queue_update(self, new_index, track_time): """Compare the queue index to determine if playback changed.""" new_track = self.get_item(new_index) - if (not self._last_track and new_track) or self._last_track != new_track: + self._cur_item_time = track_time + self._cur_index = new_index + if self._last_track != new_track: # queue track updated - # account for track changing state so trigger track change after 1 second - if self._last_track and self._last_track.streamdetails: - self._last_track.streamdetails.seconds_played = self._last_item_time - self.mass.signal_event( - EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails - ) - if new_track and new_track.streamdetails: - self.mass.signal_event(EVENT_PLAYBACK_STARTED, new_track.streamdetails) - self._last_track = new_track - if self._last_player_state != self.player.state: - self._last_player_state = self.player.state - if self.player.elapsed_time == 0 and self.player.state in [ - PlayerState.Stopped, - PlayerState.Off, - ]: - # player stopped playing - if self._last_track: - self.mass.signal_event( - EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails - ) + self._last_track = new_track + self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict()) + if self._last_track: + self._last_track.streamdetails = None # invalidate streamdetails # update vars - if track_time > 2: - # account for track changing state so keep this a few seconds behind + if self._last_item_time != track_time: self._last_item_time = track_time - self._cur_item_time = track_time - self._cur_index = new_index + self.mass.signal_event( + EVENT_QUEUE_TIME_UPDATED, + {"player_id": self.player_id, "cur_item_time": track_time}, + ) @staticmethod def __shuffle_items(queue_items): diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index 2d487e47..ab450255 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -24,7 +24,7 @@ from music_assistant.models.media_types import ( ) from music_assistant.models.musicprovider import MusicProvider from music_assistant.models.provider import ProviderType -from music_assistant.models.streamdetails import StreamDetails +from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType from music_assistant.utils import compare_strings, run_periodic from PIL import Image @@ -1077,29 +1077,38 @@ class MusicManager: param media_item: The MediaItem (track/radio) for which to request the streamdetails for. param player_id: Optionally provide the player_id which will play this stream. """ - if media_item.streamdetails: - media_item.streamdetails.player_id = player_id - return media_item.streamdetails # already present, no need to fetch again! - # always request the full db track as there might be other qualities available - # except for radio - if media_item.media_type == MediaType.Radio: - full_track = media_item - else: - full_track = await self.async_get_track( - media_item.item_id, media_item.provider, lazy=True, refresh=True + if media_item.provider == "uri": + # special type: a plain uri was added to the queue + streamdetails = StreamDetails( + type=StreamType.URL, + provider="uri", + item_id=media_item.item_id, + path=media_item.item_id, + content_type=ContentType(media_item.item_id.split(".")[-1]), + sample_rate=44100, + bit_depth=16, ) - # sort by quality and check track availability - for prov_media in sorted( - full_track.provider_ids, key=lambda x: x.quality, reverse=True - ): - # get streamdetails from provider - music_prov = self.mass.get_provider(prov_media.provider) - if not music_prov: - continue # provider temporary unavailable ? + else: + # always request the full db track as there might be other qualities available + # except for radio + if media_item.media_type == MediaType.Radio: + full_track = media_item + else: + full_track = await self.async_get_track( + media_item.item_id, media_item.provider, lazy=True, refresh=True + ) + # sort by quality and check track availability + for prov_media in sorted( + full_track.provider_ids, key=lambda x: x.quality, reverse=True + ): + # get streamdetails from provider + music_prov = self.mass.get_provider(prov_media.provider) + if not music_prov: + continue # provider temporary unavailable ? - streamdetails = await music_prov.async_get_stream_details( - prov_media.item_id - ) + streamdetails = await music_prov.async_get_stream_details( + prov_media.item_id + ) if streamdetails: streamdetails.player_id = player_id diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index 62459f4f..938e41f5 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -24,7 +24,6 @@ from music_assistant.models.player import ( from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption from music_assistant.models.playerprovider import PlayerProvider from music_assistant.models.provider import ProviderType -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType from music_assistant.utils import ( async_iter_items, callback, @@ -272,18 +271,15 @@ class PlayerManager: queue_item = QueueItem( Track( item_id=uri, - provider="", - name="uri", + provider="uri", + name=uri, ) ) - queue_item.streamdetails = StreamDetails( - type=StreamType.URL, - provider="", - item_id=uri, - path=uri, - content_type=ContentType(uri.split(".")[-1]), - sample_rate=44100, - bit_depth=16, + # generate uri for this queue item + queue_item.uri = "%s/stream/%s/%s" % ( + self.mass.web.internal_url, + player_id, + queue_item.queue_item_id, ) # turn on player await self.async_cmd_power_on(player_id) @@ -388,6 +384,22 @@ class PlayerManager: for child_player_id in player.group_childs: if self._players.get(child_player_id): await self.async_cmd_power_off(child_player_id) + else: + # if this was the last powered player in the group, turn off group + for parent_player_id in player.group_parents: + parent_player = self._players.get(parent_player_id) + if not parent_player: + continue + has_powered_players = False + for child_player_id in parent_player.group_childs: + if child_player_id == player_id: + continue + child_player = self._players.get(child_player_id) + if child_player and child_player.powered: + has_powered_players = True + break + if not has_powered_players: + await self.async_cmd_power_off(parent_player_id) async def async_cmd_power_toggle(self, player_id: str): """ @@ -591,8 +603,6 @@ class PlayerManager: @callback def __get_player_state(self, player: Player, active_parent: str): """Get final/calculated player's state.""" - if not player.available or not player.powered: - return PlayerState.Off if active_parent != player.player_id: # use group state return self._players[active_parent].state diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 81facca8..9cce49c1 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -76,8 +76,6 @@ class ChromecastPlayer: @property def state(self) -> PlayerState: """Return the state of the player.""" - if not self._powered: - return PlayerState.Off if self.media_status is None: return PlayerState.Stopped if self.media_status.player_is_playing: diff --git a/music_assistant/providers/demo/demo_playerprovider.py b/music_assistant/providers/demo/demo_playerprovider.py index 01a740e2..082de6e0 100644 --- a/music_assistant/providers/demo/demo_playerprovider.py +++ b/music_assistant/providers/demo/demo_playerprovider.py @@ -104,12 +104,17 @@ class DemoPlayerProvider(PlayerProvider): player.current_uri = uri player.sox = subprocess.Popen(["play", uri]) player.state = PlayerState.Playing + player.powered = True self.mass.add_job(self.mass.player_manager.async_update_player(player)) async def report_progress(): """Report fake progress while sox is playing.""" player.elapsed_time = 0 - while player.sox and not player.sox.poll(): + while ( + player.state == PlayerState.Playing + and player.sox + and not player.sox.poll() + ): await asyncio.sleep(1) player.elapsed_time += 1 self.mass.add_job(self.mass.player_manager.async_update_player(player)) diff --git a/music_assistant/providers/home_assistant/__init__.py b/music_assistant/providers/home_assistant/__init__.py index d43dff50..db96378c 100644 --- a/music_assistant/providers/home_assistant/__init__.py +++ b/music_assistant/providers/home_assistant/__init__.py @@ -3,24 +3,17 @@ import logging from typing import List -import slugify as slug from hass_client import ( EVENT_CONNECTED, EVENT_STATE_CHANGED, IS_SUPERVISOR, HomeAssistant, ) -from music_assistant.constants import ( - CONF_URL, - EVENT_PLAYER_ADDED, - EVENT_PLAYER_CHANGED, - EVENT_PLAYER_REMOVED, -) +from music_assistant.constants import CONF_URL from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.models.media_types import MediaType -from music_assistant.models.player import Player, PlayerControl, PlayerControlType +from music_assistant.models.player import PlayerControl, PlayerControlType from music_assistant.models.provider import Provider -from music_assistant.utils import callback, run_periodic, try_parse_float +from music_assistant.utils import callback, try_parse_float PROV_ID = "homeassistant" PROV_NAME = "Home Assistant integration" @@ -40,14 +33,6 @@ CONFIG_ENTRY_TOKEN = ConfigEntry( entry_type=ConfigEntryType.PASSWORD, description_key="hass_token", ) -CONFIG_ENTRY_PUBLISH_PLAYERS = ConfigEntry( - entry_key=CONF_PUBLISH_PLAYERS, - entry_type=ConfigEntryType.BOOL, - description_key=CONF_PUBLISH_PLAYERS, - default_value=True, -) - -# TODO: handle player removals and renames in publishing to hass async def async_setup(mass): @@ -59,7 +44,6 @@ async def async_setup(mass): class HomeAssistantPlugin(Provider): """Homeassistant plugin. - allows publishing of our players to hass allows using hass entities (like switches, media_players or gui inputs) to be triggered """ @@ -69,7 +53,6 @@ class HomeAssistantPlugin(Provider): self._tasks = [] self._tracked_entities = [] self._sources = [] - self._published_players = {} super().__init__(*args, **kwargs) @property @@ -90,7 +73,6 @@ class HomeAssistantPlugin(Provider): entries.append(CONFIG_ENTRY_URL) entries.append(CONFIG_ENTRY_TOKEN) entries += [ - CONFIG_ENTRY_PUBLISH_PLAYERS, ConfigEntry( entry_key=CONF_POWER_ENTITIES, entry_type=ConfigEntryType.STRING, @@ -121,12 +103,7 @@ class HomeAssistantPlugin(Provider): ) # register callbacks self._hass.register_event_callback(self.__async_hass_event) - self.mass.add_event_listener( - self.__async_mass_event, - [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED, EVENT_PLAYER_REMOVED], - ) await self._hass.async_connect() - self._tasks.append(self.mass.add_job(self.__async_get_sources())) return True async def async_on_stop(self): @@ -136,167 +113,16 @@ class HomeAssistantPlugin(Provider): if self._hass: await self._hass.async_close() - async def __async_mass_event(self, event, event_data): - """Receive event from Music Assistant.""" - if event in [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED]: - await self.__async_publish_player(event_data) - # TODO: player removals - async def __async_hass_event(self, event_type, event_data): """Receive event from Home Assistant.""" if event_type == EVENT_STATE_CHANGED: if event_data["entity_id"] in self._tracked_entities: new_state = event_data["new_state"] await self.__async_update_player_controls(new_state) - elif event_type == "call_service" and event_data["domain"] == "media_player": - await self.__async_handle_player_command( - event_data["service"], event_data["service_data"] - ) elif event_type == EVENT_CONNECTED: # register player controls on connect self.mass.add_job(self.__async_register_player_controls()) - async def __async_handle_player_command(self, service, service_data): - """Handle forwarded service call for one of our players.""" - if isinstance(service_data["entity_id"], list): - # can be a list of entity ids if action fired on multiple items - entity_ids = service_data["entity_id"] - else: - entity_ids = [service_data["entity_id"]] - for entity_id in entity_ids: - if entity_id in self._published_players: - # call is for one of our players so handle it - player_id = self._published_players[entity_id] - if not self.mass.player_manager.get_player(player_id): - return - if service == "turn_on": - await self.mass.player_manager.async_cmd_power_on(player_id) - elif service == "turn_off": - await self.mass.player_manager.async_cmd_power_off(player_id) - elif service == "toggle": - await self.mass.player_manager.async_cmd_power_toggle(player_id) - elif service == "volume_mute": - await self.mass.player_manager.async_cmd_volume_mute( - player_id, service_data["is_volume_muted"] - ) - elif service == "volume_up": - await self.mass.player_manager.async_cmd_volume_up(player_id) - elif service == "volume_down": - await self.mass.player_manager.async_cmd_volume_down(player_id) - elif service == "volume_set": - volume_level = service_data["volume_level"] * 100 - await self.mass.player_manager.async_cmd_volume_set( - player_id, volume_level - ) - elif service == "media_play": - await self.mass.player_manager.async_cmd_play(player_id) - elif service == "media_pause": - await self.mass.player_manager.async_cmd_pause(player_id) - elif service == "media_stop": - await self.mass.player_manager.async_cmd_stop(player_id) - elif service == "media_next_track": - await self.mass.player_manager.async_cmd_next(player_id) - elif service == "media_play_pause": - await self.mass.player_manager.async_cmd_play_pause(player_id) - elif service in ["play_media", "select_source"]: - return await self.__async_handle_play_media(player_id, service_data) - else: - LOGGER.error( - "%s service is unhandled. Service data: %s", - service, - service_data, - ) - - async def __async_handle_play_media(self, player_id, service_data): - """Handle play media request from homeassistant.""" - media_content_id = service_data.get("media_content_id") - if not media_content_id: - media_content_id = service_data.get("source") - queue_opt = "add" if service_data.get("enqueue") else "play" - if "://" not in media_content_id: - media_items = [] - for playlist_str in media_content_id.split(","): - playlist_str = playlist_str.strip() - playlist = ( - await self.mass.music_manager.async_get_library_playlist_by_name( - playlist_str - ) - ) - if playlist: - media_items.append(playlist) - else: - radio = await self.mass.music_manager.async_get_radio_by_name( - playlist_str - ) - if radio: - media_items.append(radio) - queue_opt = "play" - return await self.mass.player_manager.async_play_media( - player_id, media_items, queue_opt - ) - if "spotify://playlist" in media_content_id: - # TODO: handle parsing of other uri's here - playlist = await self.mass.music_manager.async_getplaylist( - "spotify", media_content_id.split(":")[-1] - ) - return await self.mass.player_manager.async_play_media( - player_id, playlist, queue_opt - ) - - async def __async_publish_player(self, player: Player): - """Publish player details to Home Assistant.""" - if not self.mass.config.providers[PROV_ID][CONF_PUBLISH_PLAYERS]: - return False - if not player.available: - return - player_id = player.player_id - entity_id = ( - "media_player.mass_" + slug.slugify(player.name, separator="_").lower() - ) - player_queue = self.mass.player_manager.get_player_queue(player_id) - cur_item = player_queue.cur_item if player_queue else None - state_attributes = { - "supported_features": 196541, - "friendly_name": player.name, - "source_list": self._sources, - "source": "unknown", - "volume_level": player.volume_level / 100, - "is_volume_muted": player.muted, - "media_position_updated_at": player.updated_at.isoformat(), - "media_duration": cur_item.duration if cur_item else None, - "media_position": player_queue.cur_item_time if player_queue else None, - "media_title": cur_item.name if cur_item else None, - "media_artist": cur_item.artists[0].name - if cur_item and cur_item.artists - else None, - "media_album_name": cur_item.album.name - if cur_item and cur_item.album - else None, - "entity_picture": "", - "mass_player_id": player_id, - } - if cur_item: - host = self.mass.web.internal_url - item_type = "radio" if cur_item.media_type == MediaType.Radio else "track" - # pylint: disable=line-too-long - img_url = f"{host}/api/{item_type}/{cur_item.item_id}/thumb?provider={cur_item.provider}" - state_attributes["entity_picture"] = img_url - self._published_players[entity_id] = player.player_id - await self._hass.async_set_state(entity_id, player.state, state_attributes) - - @run_periodic(600) - async def __async_get_sources(self): - """We build a list of all playlists to use as player sources.""" - # pylint: disable=attribute-defined-outside-init - self._sources = [ - playlist.name - async for playlist in self.mass.music_manager.async_get_library_playlists() - ] - self._sources += [ - playlist.name - async for playlist in self.mass.music_manager.async_get_library_radios() - ] - @callback def __get_power_control_entities(self): """Return list of entities that can be used as power control.""" diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 7d50f963..3699477f 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -10,7 +10,7 @@ from music_assistant.app_vars import get_app_var # noqa # pylint: disable=all from music_assistant.constants import ( CONF_PASSWORD, CONF_USERNAME, - EVENT_PLAYBACK_STOPPED, + EVENT_STREAM_ENDED, EVENT_STREAM_STARTED, ) from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType @@ -95,7 +95,7 @@ class QobuzProvider(MusicProvider): self.__logged_in = False self._throttler = Throttler(rate_limit=4, period=1) self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_STARTED) - self.mass.add_event_listener(self.async_mass_event, EVENT_PLAYBACK_STOPPED) + self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_ENDED) return True async def async_search( @@ -392,7 +392,7 @@ class QobuzProvider(MusicProvider): } ] await self.__async_post_data("track/reportStreamingStart", data=events) - elif msg == EVENT_PLAYBACK_STOPPED and msg_details.provider == PROV_ID: + elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID: # report streaming ended to qobuz # if msg_details.details < 6: # return ????????????? TODO -- 2.34.1