diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index becf90b04cd..4a0409df383 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .browse_media import async_browse_media from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES -from .models import HomeAssistantSpotifyData +from .coordinator import SpotifyCoordinator +from .models import SpotifyData from .util import ( is_spotify_media_type, resolve_spotify_media_type, @@ -39,7 +40,7 @@ __all__ = [ ] -type SpotifyConfigEntry = ConfigEntry[HomeAssistantSpotifyData] +type SpotifyConfigEntry = ConfigEntry[SpotifyData] async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: @@ -54,13 +55,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b spotify = Spotify(auth=session.token["access_token"]) - try: - current_user = await hass.async_add_executor_job(spotify.me) - except SpotifyException as err: - raise ConfigEntryNotReady from err + coordinator = SpotifyCoordinator(hass, spotify, session) - if not current_user: - raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() async def _update_devices() -> list[dict[str, Any]]: if not session.valid_token: @@ -92,12 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b ) await device_coordinator.async_config_entry_first_refresh() - entry.runtime_data = HomeAssistantSpotifyData( - client=spotify, - current_user=current_user, - devices=device_coordinator, - session=session, - ) + entry.runtime_data = SpotifyData(coordinator, session, device_coordinator) if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES): raise ConfigEntryAuthFailed diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index abcb6df6205..58b14e1183a 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -16,11 +16,11 @@ from homeassistant.components.media_player import ( MediaClass, MediaType, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES -from .models import HomeAssistantSpotifyData from .util import fetch_image_url BROWSE_LIMIT = 48 @@ -183,7 +183,7 @@ async def async_browse_media( or hass.config_entries.async_get_entry(host.upper()) ) is None - or not isinstance(entry.runtime_data, HomeAssistantSpotifyData) + or entry.state is not ConfigEntryState.LOADED ): raise BrowseError("Invalid Spotify account specified") media_content_id = parsed_url.name @@ -191,9 +191,9 @@ async def async_browse_media( result = await async_browse_media_internal( hass, - info.client, + info.coordinator.client, info.session, - info.current_user, + info.coordinator.current_user, media_content_type, media_content_id, can_play_artist=can_play_artist, diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py new file mode 100644 index 00000000000..72efdefa7a5 --- /dev/null +++ b/homeassistant/components/spotify/coordinator.py @@ -0,0 +1,113 @@ +"""Coordinator for Spotify.""" + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import Any + +from spotipy import Spotify, SpotifyException + +from homeassistant.components.media_player import MediaType +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SpotifyCoordinatorData: + """Class to hold Spotify data.""" + + current_playback: dict[str, Any] + position_updated_at: datetime | None + playlist: dict[str, Any] | None + + +# This is a minimal representation of the DJ playlist that Spotify now offers +# The DJ is not fully integrated with the playlist API, so needs to have the +# playlist response mocked in order to maintain functionality +SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} + + +class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): + """Class to manage fetching Spotify data.""" + + current_user: dict[str, Any] + + def __init__( + self, hass: HomeAssistant, client: Spotify, session: OAuth2Session + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = client + self._playlist: dict[str, Any] | None = None + self.session = session + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + self.current_user = await self.hass.async_add_executor_job(self.client.me) + except SpotifyException as err: + raise UpdateFailed("Error communicating with Spotify API") from err + if not self.current_user: + raise UpdateFailed("Could not retrieve user") + + async def _async_update_data(self) -> SpotifyCoordinatorData: + if not self.session.valid_token: + await self.session.async_ensure_token_valid() + await self.hass.async_add_executor_job( + self.client.set_auth, self.session.token["access_token"] + ) + return await self.hass.async_add_executor_job(self._sync_update_data) + + def _sync_update_data(self) -> SpotifyCoordinatorData: + current = self.client.current_playback(additional_types=[MediaType.EPISODE]) + currently_playing = current or {} + # Record the last updated time, because Spotify's timestamp property is unreliable + # and doesn't actually return the fetch time as is mentioned in the API description + position_updated_at = dt_util.utcnow() if current is not None else None + + context = currently_playing.get("context") or {} + + # For some users in some cases, the uri is formed like + # "spotify:user:{name}:playlist:{id}" and spotipy wants + # the type to be playlist. + uri = context.get("uri") + if uri is not None: + parts = uri.split(":") + if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist": + uri = ":".join([parts[0], parts[3], parts[4]]) + + if context and (self._playlist is None or self._playlist["uri"] != uri): + self._playlist = None + if context["type"] == MediaType.PLAYLIST: + # The Spotify API does not currently support doing a lookup for + # the DJ playlist,so just use the minimal mock playlist object + if uri == SPOTIFY_DJ_PLAYLIST["uri"]: + self._playlist = SPOTIFY_DJ_PLAYLIST + else: + # Make sure any playlist lookups don't break the current + # playback state update + try: + self._playlist = self.client.playlist(uri) + except SpotifyException: + _LOGGER.debug( + "Unable to load spotify playlist '%s'. " + "Continuing without playlist data", + uri, + ) + self._playlist = None + return SpotifyCoordinatorData( + current_playback=currently_playing, + position_updated_at=position_updated_at, + playlist=self._playlist, + ) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 3653bdb149a..ad27e2919b2 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations -from asyncio import run_coroutine_threadsafe from collections.abc import Callable +import datetime as dt from datetime import timedelta import logging from typing import Any, Concatenate @@ -27,12 +27,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import SpotifyConfigEntry from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES -from .models import HomeAssistantSpotifyData +from .coordinator import SpotifyCoordinator from .util import fetch_image_url _LOGGER = logging.getLogger(__name__) @@ -63,10 +66,6 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } -# This is a minimal representation of the DJ playlist that Spotify now offers -# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality -SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} - async def async_setup_entry( hass: HomeAssistant, @@ -74,12 +73,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" + data = entry.runtime_data spotify = SpotifyMediaPlayer( - entry.runtime_data, + data.coordinator, + data.devices, entry.data[CONF_ID], entry.title, ) - async_add_entities([spotify], True) + async_add_entities([spotify]) def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R]( @@ -110,7 +111,7 @@ def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R return wrapper -class SpotifyMediaPlayer(MediaPlayerEntity): +class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntity): """Representation of a Spotify controller.""" _attr_has_entity_name = True @@ -120,97 +121,106 @@ class SpotifyMediaPlayer(MediaPlayerEntity): def __init__( self, - data: HomeAssistantSpotifyData, + coordinator: SpotifyCoordinator, + device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]], user_id: str, name: str, ) -> None: """Initialize.""" - self._id = user_id - self.data = data + super().__init__(coordinator) + self.devices = device_coordinator self._attr_unique_id = user_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, user_id)}, manufacturer="Spotify AB", - model=f"Spotify {data.current_user['product']}", + model=f"Spotify {coordinator.current_user['product']}", name=f"Spotify {name}", entry_type=DeviceEntryType.SERVICE, configuration_url="https://open.spotify.com", ) - self._currently_playing: dict | None = {} - self._playlist: dict | None = None - self._restricted_device: bool = False + + @property + def currently_playing(self) -> dict[str, Any]: + """Return the current playback.""" + return self.coordinator.data.current_playback @property def supported_features(self) -> MediaPlayerEntityFeature: """Return the supported features.""" - if self.data.current_user["product"] != "premium": + if self.coordinator.current_user["product"] != "premium": return MediaPlayerEntityFeature(0) - if self._restricted_device or not self._currently_playing: + if not self.currently_playing or self.currently_playing.get("device", {}).get( + "is_restricted" + ): return MediaPlayerEntityFeature.SELECT_SOURCE return SUPPORT_SPOTIFY @property def state(self) -> MediaPlayerState: """Return the playback state.""" - if not self._currently_playing: + if not self.currently_playing: return MediaPlayerState.IDLE - if self._currently_playing["is_playing"]: + if self.currently_playing["is_playing"]: return MediaPlayerState.PLAYING return MediaPlayerState.PAUSED @property def volume_level(self) -> float | None: """Return the device volume.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100 + return self.currently_playing.get("device", {}).get("volume_percent", 0) / 100 @property def media_content_id(self) -> str | None: """Return the media URL.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("uri") @property def media_content_type(self) -> str | None: """Return the media type.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} is_episode = item.get("type") == MediaType.EPISODE return MediaType.PODCAST if is_episode else MediaType.MUSIC @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - if ( - self._currently_playing is None - or self._currently_playing.get("item") is None - ): + if self.currently_playing is None or self.currently_playing.get("item") is None: return None - return self._currently_playing["item"]["duration_ms"] / 1000 + return self.currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" if ( - not self._currently_playing - or self._currently_playing.get("progress_ms") is None + not self.currently_playing + or self.currently_playing.get("progress_ms") is None ): return None - return self._currently_playing["progress_ms"] / 1000 + return self.currently_playing["progress_ms"] / 1000 + + @property + def media_position_updated_at(self) -> dt.datetime | None: + """When was the position of the current playing media valid.""" + if not self.currently_playing: + return None + return self.coordinator.data.position_updated_at @property def media_image_url(self) -> str | None: """Return the media image URL.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: if item["images"]: return fetch_image_url(item) @@ -225,18 +235,18 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_title(self) -> str | None: """Return the media title.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> str | None: """Return the media artist.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["publisher"] @@ -245,10 +255,10 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_album_name(self) -> str | None: """Return the media album.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["name"] @@ -257,43 +267,43 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" - if self._playlist is None: + if self.coordinator.data.playlist is None: return None - return self._playlist["name"] + return self.coordinator.data.playlist["name"] @property def source(self) -> str | None: """Return the current playback device.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("device", {}).get("name") + return self.currently_playing.get("device", {}).get("name") @property def source_list(self) -> list[str] | None: """Return a list of source devices.""" - return [device["name"] for device in self.data.devices.data] + return [device["name"] for device in self.devices.data] @property def shuffle(self) -> bool | None: """Shuffling state.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("shuffle_state") + return self.currently_playing.get("shuffle_state") @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( - not self._currently_playing - or (repeat_state := self._currently_playing.get("repeat_state")) is None + not self.currently_playing + or (repeat_state := self.currently_playing.get("repeat_state")) is None ): return None return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) @@ -301,32 +311,32 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @spotify_exception_handler def set_volume_level(self, volume: float) -> None: """Set the volume level.""" - self.data.client.volume(int(volume * 100)) + self.coordinator.client.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" - self.data.client.start_playback() + self.coordinator.client.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" - self.data.client.pause_playback() + self.coordinator.client.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" - self.data.client.previous_track() + self.coordinator.client.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" - self.data.client.next_track() + self.coordinator.client.next_track() @spotify_exception_handler def media_seek(self, position: float) -> None: """Send seek command.""" - self.data.client.seek_track(int(position * 1000)) + self.coordinator.client.seek_track(int(position * 1000)) @spotify_exception_handler def play_media( @@ -354,11 +364,11 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return if ( - self._currently_playing - and not self._currently_playing.get("device") - and self.data.devices.data + self.currently_playing + and not self.currently_playing.get("device") + and self.devices.data ): - kwargs["device_id"] = self.data.devices.data[0].get("id") + kwargs["device_id"] = self.devices.data[0].get("id") if enqueue == MediaPlayerEnqueue.ADD: if media_type not in { @@ -369,17 +379,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity): raise ValueError( f"Media type {media_type} is not supported when enqueue is ADD" ) - self.data.client.add_to_queue(media_id, kwargs.get("device_id")) + self.coordinator.client.add_to_queue(media_id, kwargs.get("device_id")) return - self.data.client.start_playback(**kwargs) + self.coordinator.client.start_playback(**kwargs) @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" - for device in self.data.devices.data: + for device in self.devices.data: if device["name"] == source: - self.data.client.transfer_playback( + self.coordinator.client.transfer_playback( device["id"], self.state == MediaPlayerState.PLAYING ) return @@ -387,66 +397,14 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" - self.data.client.shuffle(shuffle) + self.coordinator.client.shuffle(shuffle) @spotify_exception_handler def set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: raise ValueError(f"Unsupported repeat mode: {repeat}") - self.data.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) - - @spotify_exception_handler - def update(self) -> None: - """Update state and attributes.""" - if not self.enabled: - return - - if not self.data.session.valid_token or self.data.client is None: - run_coroutine_threadsafe( - self.data.session.async_ensure_token_valid(), self.hass.loop - ).result() - self.data.client.set_auth(auth=self.data.session.token["access_token"]) - - current = self.data.client.current_playback( - additional_types=[MediaType.EPISODE] - ) - self._currently_playing = current or {} - # Record the last updated time, because Spotify's timestamp property is unreliable - # and doesn't actually return the fetch time as is mentioned in the API description - self._attr_media_position_updated_at = utcnow() if current is not None else None - - context = self._currently_playing.get("context") or {} - - # For some users in some cases, the uri is formed like - # "spotify:user:{name}:playlist:{id}" and spotipy wants - # the type to be playlist. - uri = context.get("uri") - if uri is not None: - parts = uri.split(":") - if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist": - uri = ":".join([parts[0], parts[3], parts[4]]) - - if context and (self._playlist is None or self._playlist["uri"] != uri): - self._playlist = None - if context["type"] == MediaType.PLAYLIST: - # The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object - if uri == SPOTIFY_DJ_PLAYLIST["uri"]: - self._playlist = SPOTIFY_DJ_PLAYLIST - else: - # Make sure any playlist lookups don't break the current playback state update - try: - self._playlist = self.data.client.playlist(uri) - except SpotifyException: - _LOGGER.debug( - "Unable to load spotify playlist '%s'. Continuing without playlist data", - uri, - ) - self._playlist = None - - device = self._currently_playing.get("device") - if device is not None: - self._restricted_device = device["is_restricted"] + self.coordinator.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) async def async_browse_media( self, @@ -457,9 +415,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return await async_browse_media_internal( self.hass, - self.data.client, - self.data.session, - self.data.current_user, + self.coordinator.client, + self.coordinator.session, + self.coordinator.current_user, media_content_type, media_content_id, ) @@ -475,5 +433,5 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.data.devices.async_add_listener(self._handle_devices_update) + self.devices.async_add_listener(self._handle_devices_update) ) diff --git a/homeassistant/components/spotify/models.py b/homeassistant/components/spotify/models.py index bbec134d89d..daeee560d58 100644 --- a/homeassistant/components/spotify/models.py +++ b/homeassistant/components/spotify/models.py @@ -3,17 +3,16 @@ from dataclasses import dataclass from typing import Any -from spotipy import Spotify - from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .coordinator import SpotifyCoordinator + @dataclass -class HomeAssistantSpotifyData: - """Spotify data stored in the Home Assistant data object.""" +class SpotifyData: + """Class to hold Spotify data.""" - client: Spotify - current_user: dict[str, Any] - devices: DataUpdateCoordinator[list[dict[str, Any]]] + coordinator: SpotifyCoordinator session: OAuth2Session + devices: DataUpdateCoordinator[list[dict[str, Any]]] diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 3f248b54529..722851d097c 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -10,12 +10,14 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.spotify import DOMAIN +from homeassistant.components.spotify.const import DOMAIN, SPOTIFY_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +SCOPES = " ".join(SPOTIFY_SCOPES) + @pytest.fixture def mock_config_entry_1() -> MockConfigEntry: @@ -30,7 +32,7 @@ def mock_config_entry_1() -> MockConfigEntry: "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RefreshToken", - "scope": "playlist-read-private ...", + "scope": SCOPES, "expires_at": 1724198975.8829377, }, "id": "32oesphrnacjcf7vw5bf6odx3oiu", @@ -54,7 +56,7 @@ def mock_config_entry_2() -> MockConfigEntry: "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RefreshToken", - "scope": "playlist-read-private ...", + "scope": SCOPES, "expires_at": 1724198975.8829377, }, "id": "55oesphrnacjcf7vw5bf6odx3oiu", @@ -123,6 +125,4 @@ async def spotify_setup( mock_config_entry_2.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_2.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done(wait_background_tasks=True) yield