queue_id: str | None = None
seconds_streamed: float | None = None
target_loudness: float | None = None
+ bypass_loudness_normalization: bool = False
def __str__(self) -> str:
"""Return pretty printable string of object."""
if (queue := self.get(queue_id)) is None or not queue.active:
# TODO: forward to underlying player if not active
return
- current_index = self._queues[queue_id].current_index
- if (next_index := self._get_next_index(queue_id, current_index, True)) is not None:
- await self.play_index(queue_id, next_index)
+ idx = self._queues[queue_id].current_index
+ while True:
+ try:
+ if (next_index := self._get_next_index(queue_id, idx, True)) is not None:
+ await self.play_index(queue_id, next_index)
+ break
+ except MediaNotFoundError:
+ self.logger.warning(
+ "Failed to fetch next track for queue %s - trying next item", queue.display_name
+ )
+ idx += 1
@api_command("player_queues/previous")
async def previous(self, queue_id: str) -> None:
if not queue_item:
raise web.HTTPNotFound(reason=f"Unknown Queue item: {queue_item_id}")
if not queue_item.streamdetails:
- # raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
- queue_item.streamdetails = await get_stream_details(
- mass=self.mass, queue_item=queue_item
- )
+ try:
+ queue_item.streamdetails = await get_stream_details(
+ mass=self.mass, queue_item=queue_item
+ )
+ except Exception as e:
+ self.logger.error(
+ "Failed to get streamdetails for QueueItem %s: %s", queue_item_id, e
+ )
+ raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
# work out output format/details
output_format = await self._get_output_format(
output_format_str=request.match_info["fmt"],
extra_input_args = []
# add loudnorm filter: volume normalization
# more info: https://k.ylo.ph/2016/04/04/loudnorm.html
- if streamdetails.target_loudness is not None:
+ if (
+ streamdetails.target_loudness is not None
+ and not streamdetails.bypass_loudness_normalization
+ ):
if streamdetails.loudness:
# we have a measurement so we can do linear mode
target_loudness = streamdetails.target_loudness
# handle skip/fade_in details
streamdetails.seek_position = seek_position
streamdetails.fade_in = fade_in
+ if not streamdetails.duration:
+ streamdetails.duration = queue_item.duration
# handle volume normalization details
is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration
- bypass_normalization = (
+ streamdetails.bypass_loudness_normalization = (
is_radio
and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_RADIO)
) or (
streamdetails.duration is not None
- and streamdetails.duration < 60
+ and streamdetails.duration < 30
and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_SHORT)
)
- if not bypass_normalization and not streamdetails.loudness:
+ if not streamdetails.loudness:
streamdetails.loudness = await mass.music.get_track_loudness(
streamdetails.item_id, streamdetails.provider
)
player_settings = await mass.config.get_player_config(streamdetails.queue_id)
- if bypass_normalization or not player_settings.get_value(CONF_VOLUME_NORMALIZATION):
+ if not player_settings.get_value(CONF_VOLUME_NORMALIZATION):
streamdetails.target_loudness = None
else:
streamdetails.target_loudness = player_settings.get_value(CONF_VOLUME_NORMALIZATION_TARGET)
- if not streamdetails.duration:
- streamdetails.duration = queue_item.duration
return streamdetails
str(output_format.sample_rate),
"-ac",
str(output_format.channels),
- output_path,
]
if output_format.output_format_str == "flac":
output_args += ["-compression_level", "6"]
+ output_args += [output_path]
# edge case: source file is not stereo - downmix to stereo
if input_format.channels > 2 and output_format.channels == 2:
raise RuntimeError(msg)
-async def get_package_version(pkg_name: str) -> str:
+async def get_package_version(pkg_name: str) -> str | None:
"""
Return the version of an installed (python) package.
- Will return `0.0.0` if the package is not found.
+ Will return None if the package is not found.
"""
try:
- installed_version = await asyncio.to_thread(pkg_version, pkg_name)
- if installed_version is None:
- return "0.0.0" # type: ignore[unreachable]
- return installed_version
+ return await asyncio.to_thread(pkg_version, pkg_name)
except PackageNotFoundError:
- return "0.0.0"
+ return None
async def get_ips(include_ipv6: bool = False, ignore_loopback: bool = True) -> set[str]:
StreamType,
)
from music_assistant.common.models.errors import (
+ AudioError,
LoginFailed,
MediaNotFoundError,
ResourceTemporarilyUnavailable,
"""Return the audio stream for the provider item."""
auth_info = await self.login()
librespot = await self.get_librespot_binary()
+ spotify_uri = f"spotify://track:{streamdetails.item_id}"
args = [
librespot,
"-c",
"--backend",
"pipe",
"--single-track",
- f"spotify://track:{streamdetails.item_id}",
+ spotify_uri,
"--token",
auth_info["access_token"],
]
args += ["--start-position", str(int(seek_position))]
chunk_size = get_chunksize(streamdetails.audio_format)
stderr = None if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL) else False
- async with AsyncProcess(
- args,
- stdout=True,
- stderr=stderr,
- name="librespot",
- ) as librespot_proc:
- async for chunk in librespot_proc.iter_any(chunk_size):
- yield chunk
+ self.logger.log(VERBOSE_LOG_LEVEL, f"Start streaming {spotify_uri} using librespot")
+ for retry in (True, False):
+ async with AsyncProcess(
+ args,
+ stdout=True,
+ stderr=stderr,
+ name="librespot",
+ ) as librespot_proc:
+ async for chunk in librespot_proc.iter_any(chunk_size):
+ yield chunk
+ if librespot_proc.returncode == 0:
+ self.logger.log(VERBOSE_LOG_LEVEL, f"Streaming {spotify_uri} ready.")
+ break
+ if not retry:
+ raise AudioError(
+ f"Failed to stream {spotify_uri} - error: {librespot_proc.returncode}"
+ )
+ # do one retry attempt
+ auth_info = await self.login(force_refresh=True)
def _parse_artist(self, artist_obj):
"""Parse spotify artist object to generic layout."""
playlist.cache_checksum = str(playlist_obj["snapshot_id"])
return playlist
- async def login(self, retry: bool = True) -> dict:
+ async def login(self, retry: bool = True, force_refresh: bool = False) -> dict:
"""Log-in Spotify and return Auth/token info."""
# return existing token if we have one in memory
- if self._auth_info and (self._auth_info["expires_at"] > (time.time() - 300)):
+ if self._auth_info and (
+ self._auth_info["expires_at"] > (time.time() - 1800 if force_refresh else 120)
+ ):
return self._auth_info
# request new access token using the refresh token
if not (refresh_token := self.config.get_value(CONF_REFRESH_TOKEN)):
"""Start running the Music Assistant server."""
self.loop = asyncio.get_running_loop()
self.running_as_hass_addon = await is_hass_supervisor()
- self.version = await get_package_version("music_assistant")
+ self.version = await get_package_version("music_assistant") or "0.0.0"
# create shared zeroconf instance
# TODO: enumerate interfaces and enable IPv6 support
self.aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only)