| int
| float
| bool
+ | list[tuple[int, int]]
| tuple[int, int]
| list[str]
| list[int]
- | list[tuple[int, int]]
| Enum
| None
)
category="audio",
)
+CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED = ConfigEntry.from_dict(
+ {**CONF_ENTRY_ENFORCE_MP3.to_dict(), "default_value": True}
+)
+
CONF_ENTRY_SYNC_ADJUST = ConfigEntry(
key=CONF_SYNC_ADJUST,
type=ConfigEntryType.INTEGER,
) -> AlbumTrack:
"""Cast Track to AlbumTrack."""
album_track = track.to_dict()
- if album is None and track.album:
- album_track["album"] = track.album
- if disc_number is None:
- album_track["disc_number"] = track.disc_number
- if track_number is None:
- album_track["track_number"] = track.track_number
+ if album_track["album"] is None:
+ if not album:
+ raise InvalidDataError("AlbumTrack requires an album")
+ album_track["album"] = album.to_dict()
+ if album_track["disc_number"] is None:
+ if disc_number is None:
+ raise InvalidDataError("AlbumTrack requires a disc_number")
+ album_track["disc_number"] = disc_number
+ if album_track["track_number"] is None:
+ if track_number is None:
+ raise InvalidDataError("AlbumTrack requires a track_number")
+ album_track["track_number"] = track_number
# let mushmumaro instantiate a new object - this will ensure that valididation takes place
return AlbumTrack.from_dict(album_track)
deps.add(dep_prov.instance_id)
await self.mass.unload_provider(dep_prov.instance_id)
# (re)load the provider
- await self.mass.load_provider(config.instance_id)
+ await self.mass.load_provider_config(config)
# reload any dependants
for dep in deps:
conf = await self.get_provider_config(dep)
if not tags.duration and tags.raw.get("format", {}).get("duration"):
tags.duration = float(tags.raw["format"]["duration"])
- if file_path.endswith(".mp3") and "musicbrainzrecordingid" not in tags.tags:
+ if (
+ not file_path.startswith("http")
+ and file_path.endswith(".mp3")
+ and "musicbrainzrecordingid" not in tags.tags
+ and await asyncio.to_thread(os.path.isfile, file_path)
+ ):
# eyed3 is able to extract the musicbrainzrecordingid from the unique file id
# this is actually a bug in ffmpeg/ffprobe which does not expose this tag
# so we use this as alternative approach for mp3 files
CONF_ENTRY_SAMPLE_RATES_AIRPLAY = create_sample_rates_config_entry(44100, 16, 44100, 16, True)
+# TODO: Airplay provider
+# - split up and cleanup the code into more digestable parts
+# - Implement authentication for Apple TV
+# - Implement volume control for Apple devices using pyatv
+# - Implement metadata for Apple Apple devices using pyatv
+# - Use pyatv for communicating with original Apple devices
+# and use cliraop for actual streaming
+
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id))
elif "dmcp.device-volume=" in path:
+ if mass_player.device_info.manufacturer.lower() == "apple":
+ # Apple devices only report their (new) volume level, they dont request it
+ return
raop_volume = float(path.split("dmcp.device-volume=", 1)[-1])
volume = convert_airplay_volume(raop_volume)
- if abs(volume - mass_player.volume_level) > 2:
+ if volume != mass_player.volume_level:
self.mass.create_task(self.cmd_volume_set(player_id, volume))
+ # optimistically set the new volume to prevent bouncing around
+ mass_player.volume_level = volume
elif "dmcp.volume=" in path:
volume = int(path.split("dmcp.volume=", 1)[-1])
- if abs(volume - mass_player.volume_level) > 2:
+ if volume != mass_player.volume_level:
self.mass.create_task(self.cmd_volume_set(player_id, volume))
+ # optimistically set the new volume to prevent bouncing around
+ mass_player.volume_level = volume
elif "device-prevent-playback=1" in path:
# device switched to another source (or is powered off)
if active_stream := airplay_player.active_stream:
from typing import TYPE_CHECKING
from aiohttp.web import Request, Response
+from async_upnp_client.const import HttpRequest
from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer
if TYPE_CHECKING:
async def _handle_request(self, request: Request) -> Response:
"""Handle incoming requests."""
- headers = request.headers
- body = await request.text()
-
if request.method != "NOTIFY":
return Response(status=405)
- status = await self.event_handler.handle_notify(headers, body)
+ # transform aiohttp request to async_upnp_client request
+ http_request = HttpRequest(
+ method=request.method,
+ url=request.url,
+ headers=request.headers,
+ body=await request.text(),
+ )
+
+ status = await self.event_handler.handle_notify(http_request)
return Response(status=status)
elif await self.exists(name.title()):
artist_path = name.title()
+ if artist_path: # noqa: SIM108
+ # prefer the path as id
+ item_id = artist_path
+ else:
+ # simply use the album name as item id
+ item_id = name
+
artist = Artist(
- item_id=artist_path or name,
+ item_id=item_id,
provider=self.instance_id,
name=name,
sort_name=sort_name,
provider_mappings={
ProviderMapping(
- item_id=artist_path or name,
+ item_id=item_id,
provider_domain=self.domain,
provider_instance=self.instance_id,
- url=artist_path or name,
+ url=artist_path,
)
},
)
- if not await self.exists(artist_path):
+ if artist_path is None or not await self.exists(artist_path):
# return basic object if there is no dedicated artist folder
await self.mass.cache.set(cache_key, artist, expiration=120)
return artist
cache_key = f"{self.instance_id}-albumdata-{name}-{album_path}"
if cache := await self.mass.cache.get(cache_key):
return cache
- # create fake path if needed
- if not album_path and artists:
- album_path = artists[0].name + os.sep + name
- elif not album_path:
- album_path = name
- if not name:
- name = album_path.split(os.sep)[-1]
+ if album_path:
+ # prefer the path as id
+ item_id = album_path
+ elif artists:
+ # create fake item_id based on artist + album
+ item_id = artists[0].name + os.sep + name
+ else:
+ # simply use the album name as item id
+ album_path = name
album = Album(
- item_id=album_path,
+ item_id=item_id,
provider=self.instance_id,
name=name,
sort_name=sort_name,
artists=artists,
provider_mappings={
ProviderMapping(
- item_id=album_path,
+ item_id=item_id,
provider_domain=self.domain,
provider_instance=self.instance_id,
url=album_path,
# hunt for additional metadata and images in the folder structure
extra_path = os.path.dirname(track_path) if (track_path and not album_path) else None
for folder_path in (disc_path, album_path, extra_path):
- if not folder_path:
+ if not folder_path or not await self.exists(folder_path):
continue
nfo_file = os.path.join(folder_path, "album.nfo")
if await self.exists(nfo_file):
from __future__ import annotations
+import os
import platform
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
[m.replace(password, "########") if password else m for m in mount_cmd],
)
env_vars = {
+ **os.environ,
"USER": username,
}
if password:
from music_assistant.common.models.config_entries import (
CONF_ENTRY_CROSSFADE,
CONF_ENTRY_CROSSFADE_DURATION,
- CONF_ENTRY_ENFORCE_MP3,
+ CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
CONF_ENTRY_FLOW_MODE_ENFORCED,
ConfigEntry,
ConfigValueType,
)
from music_assistant.common.models.errors import PlayerUnavailableError, SetupFailedError
from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia
-from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, VERBOSE_LOG_LEVEL
+from music_assistant.constants import (
+ CONF_ENFORCE_MP3,
+ CONF_IP_ADDRESS,
+ CONF_PASSWORD,
+ CONF_PORT,
+ VERBOSE_LOG_LEVEL,
+)
from music_assistant.server.models.player_provider import PlayerProvider
if TYPE_CHECKING:
from music_assistant.server.models import ProviderInstanceType
AUDIOMANAGER_STREAM_MUSIC = 3
-CONF_ENFORCE_MP3 = "enforce_mp3"
async def setup(
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_CROSSFADE,
CONF_ENTRY_CROSSFADE_DURATION,
- CONF_ENTRY_ENFORCE_MP3,
+ CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
)
async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
) -> None:
"""Handle PLAY MEDIA on given player."""
player = self.mass.players.get(player_id)
- if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, False):
+ if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True):
media.uri = media.uri.replace(".flac", ".mp3")
await self._fully.playSound(media.uri, AUDIOMANAGER_STREAM_MUSIC)
player.current_media = media
try:
await self.hass.connect()
except BaseHassClientError as err:
- raise SetupFailedError from err
+ err_msg = str(err) or err.__class__.__name__
+ raise SetupFailedError(err_msg) from err
self._listen_task = self.mass.create_task(self._hass_listener())
async def unload(self) -> None:
from music_assistant.common.models.config_entries import (
CONF_ENTRY_CROSSFADE_DURATION,
CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
- CONF_ENTRY_ENFORCE_MP3,
+ CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
ConfigEntry,
ConfigValueOption,
CONF_ENFORCE_MP3 = "enforce_mp3"
-CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED = ConfigEntry.from_dict(
- {**CONF_ENTRY_ENFORCE_MP3.to_dict(), "default_value": True}
-)
PLAYER_CONFIG_ENTRIES = (
CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
async def play_media(self, player_id: str, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
- if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, False):
+ if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True):
media.uri = media.uri.replace(".flac", ".mp3")
await self.hass_prov.hass.call_service(
domain="media_player",
async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
"""Handle enqueuing of the next queue item on the player."""
- if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, False):
+ if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True):
media.uri = media.uri.replace(".flac", ".mp3")
await self.hass_prov.hass.call_service(
domain="media_player",
prov_conf: ProviderConfig,
) -> None:
"""Try to load a provider and catch errors."""
+ # cancel existing (re)load timer if needed
+ task_id = f"load_provider_{prov_conf.instance_id}"
+ if existing := self._tracked_timers.pop(task_id, None):
+ existing.cancel()
+
try:
await self._load_provider(prov_conf)
# pylint: disable=broad-except
except Exception as exc:
- if isinstance(exc, MusicAssistantError):
- LOGGER.error(
- "Error loading provider(instance) %s: %s",
- prov_conf.name or prov_conf.domain,
- str(exc),
- )
- else:
- # log full stack trace on unhandled/generic exception
- LOGGER.exception(
- "Error loading provider(instance) %s",
- prov_conf.name or prov_conf.domain,
- )
+ LOGGER.error(
+ "Error loading provider(instance) %s: %s",
+ prov_conf.name or prov_conf.instance_id,
+ str(exc) or exc.__class__.__name__,
+ # log full stack trace if debug logging is enabled
+ exc_info=exc if LOGGER.isEnabledFor(logging.DEBUG) else None,
+ )
raise
async def load_provider(
self,
instance_id: str,
- raise_on_error: bool = False,
- schedule_retry: int | None = 10,
+ allow_retry: bool = False,
) -> None:
"""Try to load a provider and catch errors."""
try:
# Was disabled before we could run
return
+ # cancel existing (re)load timer if needed
+ task_id = f"load_provider_{instance_id}"
+ if existing := self._tracked_timers.pop(task_id, None):
+ existing.cancel()
+
try:
await self.load_provider_config(prov_conf)
# pylint: disable=broad-except
except Exception as exc:
- if raise_on_error:
- raise
# if loading failed, we store the error in the config object
# so we can show something useful to the user
prov_conf.last_error = str(exc)
self.config.set(f"{CONF_PROVIDERS}/{instance_id}/last_error", str(exc))
- # auto schedule a retry if the (re)load failed
- if schedule_retry:
+
+ # auto schedule a retry if the (re)load failed (handled exceptions only)
+ if isinstance(exc, MusicAssistantError) and allow_retry:
self.call_later(
- schedule_retry,
+ 300,
self.load_provider,
instance_id,
- raise_on_error,
- min(schedule_retry + 10, 600),
+ allow_retry,
+ task_id=task_id,
)
+ else:
+ raise
async def unload_provider(self, instance_id: str) -> None:
"""Unload a provider."""
for prov_conf in prov_configs:
if not prov_conf.enabled:
continue
- tg.create_task(self.load_provider(prov_conf.instance_id))
+ tg.create_task(self.load_provider(prov_conf.instance_id, allow_retry=True))
async def _load_provider(self, conf: ProviderConfig) -> None:
"""Load (or reload) a provider."""