From 08539eb5cfd8575390c79fa6f156d6335a2fbc17 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 5 Apr 2024 17:10:14 +0200 Subject: [PATCH] Some small bugfixes (#1205) --- music_assistant/common/models/enums.py | 4 ++- music_assistant/common/models/media_items.py | 9 +++-- .../server/controllers/media/artists.py | 1 - music_assistant/server/controllers/streams.py | 28 +++++++-------- music_assistant/server/helpers/audio.py | 34 ++++++++++++++----- music_assistant/server/helpers/compare.py | 21 +++++++----- .../server/providers/airplay/__init__.py | 18 +++------- .../server/providers/fully_kiosk/__init__.py | 8 ++++- .../server/providers/theaudiodb/__init__.py | 1 + music_assistant/server/server.py | 19 ++++++++--- 10 files changed, 88 insertions(+), 55 deletions(-) diff --git a/music_assistant/common/models/enums.py b/music_assistant/common/models/enums.py index 2cfbaaa2..be699158 100644 --- a/music_assistant/common/models/enums.py +++ b/music_assistant/common/models/enums.py @@ -402,6 +402,8 @@ class ConfigEntryType(StrEnum): class StreamType(StrEnum): """Enum for the type of streamdetails.""" - HTTP = "http" + HTTP = "http" # regular http stream + HLS = "hls" # http HLS stream + ICY = "icy" # http stream with icy metadata LOCAL_FILE = "local_file" CUSTOM = "custom" diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index 577b879b..8edb7bed 100644 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -51,7 +51,8 @@ class AudioFormat(DataClassDictMixin): return int(self.sample_rate / 1000) + self.bit_depth # lossy content, bit_rate is most important score # but prefer some codecs over others - score = self.bit_rate / 100 + # rule out bitrates > 320 as that is just an error (happens e.g. for AC3 stream somehow) + score = min(320, self.bit_rate) / 100 if self.content_type in (ContentType.AAC, ContentType.OGG): score += 1 return int(score) @@ -86,7 +87,11 @@ class ProviderMapping(DataClassDictMixin): @property def quality(self) -> int: """Return quality score.""" - return self.audio_format.quality + quality = self.audio_format.quality + if "filesystem" in self.provider_domain: + # always prefer local file over online media + quality += 1 + return quality def __post_init__(self): """Call after init.""" diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index fc376a36..f9c33287 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -447,7 +447,6 @@ class ArtistsController(MediaControllerBase[Artist]): for search_str in ( f"{db_artist.name} - {provider_ref_track.name}", f"{db_artist.name} {provider_ref_track.name}", - f"{db_artist.sort_name} {provider_ref_track.sort_name}", provider_ref_track.name, ): search_results = await self.mass.music.tracks.search(search_str, provider.domain) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index f608609f..85eddb9e 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -49,7 +49,6 @@ from music_assistant.server.helpers.audio import ( get_icy_stream, get_player_filter_params, parse_loudnorm, - resolve_radio_stream, strip_silence, ) from music_assistant.server.helpers.util import get_ips @@ -682,14 +681,21 @@ class StreamsController(CoreController): """ logger = self.logger.getChild("media_stream") is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration - if is_radio or streamdetails.seek_position: + if is_radio: + streamdetails.seek_position = 0 strip_silence_begin = False - if is_radio or streamdetails.duration < 30: + strip_silence_end = False + if streamdetails.seek_position: + strip_silence_begin = False + if not streamdetails.duration or streamdetails.duration < 30: strip_silence_end = False # pcm_sample_size = chunk size = 1 second of pcm audio pcm_sample_size = pcm_format.pcm_sample_size buffer_size = ( - pcm_sample_size * 5 if (strip_silence_begin or strip_silence_end) else pcm_sample_size + pcm_sample_size * 5 + if (strip_silence_begin or strip_silence_end) + # always require a small amount of buffer to prevent livestreams stuttering + else pcm_sample_size * 2 ) # collect all arguments for ffmpeg @@ -712,16 +718,10 @@ class StreamsController(CoreController): streamdetails, seek_position=streamdetails.seek_position, ) - elif streamdetails.media_type == MediaType.RADIO: - resolved_url, supports_icy, is_hls = await resolve_radio_stream( - self.mass, streamdetails.path - ) - if supports_icy: - audio_source = get_icy_stream(self.mass, resolved_url, streamdetails) - elif is_hls: - audio_source = get_hls_stream(self.mass, resolved_url, streamdetails) - else: - audio_source = resolved_url + elif streamdetails.stream_type == StreamType.HLS: + audio_source = get_hls_stream(self.mass, streamdetails.path, streamdetails) + elif streamdetails.stream_type == StreamType.ICY: + audio_source = get_icy_stream(self.mass, streamdetails.path, streamdetails) else: audio_source = streamdetails.path extra_input_args = [] diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 02397131..fb090168 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -19,6 +19,7 @@ from music_assistant.common.helpers.global_cache import ( set_global_cache_values, ) from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads +from music_assistant.common.models.enums import MediaType, StreamType from music_assistant.common.models.errors import ( AudioError, InvalidDataError, @@ -271,6 +272,18 @@ async def get_stream_details( else: raise MediaNotFoundError(f"Unable to retrieve streamdetails for {queue_item}") + # work out how to handle radio stream + if ( + streamdetails.media_type == MediaType.RADIO + and streamdetails.stream_type == StreamType.HTTP + ): + resolved_url, is_icy, is_hls = await resolve_radio_stream(mass, streamdetails.path) + streamdetails.path = resolved_url + if is_hls: + streamdetails.stream_type = StreamType.HLS + elif is_icy: + streamdetails.stream_type = StreamType.ICY + # set queue_id on the streamdetails so we know what is being streamed streamdetails.queue_id = queue_item.queue_id # handle skip/fade_in details @@ -358,7 +371,7 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo Returns tuple; - unfolded URL as string - - bool if the URL supports ICY metadata. + - bool if the URL represents a ICY (radio) stream. - bool uf the URL represents a HLS stream/playlist. """ base_url = url.split("?")[0] @@ -366,7 +379,7 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo if cache := await mass.cache.get(cache_key): return cache is_hls = False - supports_icy = False + is_icy = False resolved_url = url timeout = ClientTimeout(total=0, connect=10, sock_read=5) try: @@ -378,7 +391,7 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo resp.raise_for_status() if not resp.headers: raise InvalidDataError("no headers found") - supports_icy = headers.get("icy-metaint") is not None + is_icy = headers.get("icy-metaint") is not None is_hls = headers.get("content-type") in HLS_CONTENT_TYPES if ( base_url.endswith((".m3u", ".m3u8", ".pls")) @@ -397,10 +410,10 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo except Exception as err: LOGGER.warning("Error while parsing radio URL %s: %s", url, err) - return (resolved_url, supports_icy, is_hls) + return (resolved_url, is_icy, is_hls) - result = (resolved_url, supports_icy, is_hls) - cache_expiration = 24 * 3600 if url == resolved_url else 600 + result = (resolved_url, is_icy, is_hls) + cache_expiration = 30 * 24 * 3600 if url == resolved_url else 600 await mass.cache.set(cache_key, result, expiration=cache_expiration) return result @@ -416,9 +429,10 @@ async def get_icy_stream( meta_int = int(headers["icy-metaint"]) while True: try: - audio_chunk = await resp.content.readexactly(meta_int) - yield audio_chunk + yield await resp.content.readexactly(meta_int) meta_byte = await resp.content.readexactly(1) + if meta_byte == b"\x00": + continue meta_length = ord(meta_byte) * 16 meta_data = await resp.content.readexactly(meta_length) except asyncio.exceptions.IncompleteReadError: @@ -676,7 +690,9 @@ async def get_preview_stream( music_prov = mass.get_provider(provider_instance_id_or_domain) streamdetails = await music_prov.get_stream_details(track_id) async for chunk in get_ffmpeg_stream( - audio_input=music_prov.get_audio_stream(streamdetails, 30), + audio_input=music_prov.get_audio_stream(streamdetails, 30) + if streamdetails.stream_type == StreamType.CUSTOM + else streamdetails.path, input_format=streamdetails.audio_format, output_format=AudioFormat(content_type=ContentType.MP3), extra_input_args=["-to", "30"], diff --git a/music_assistant/server/helpers/compare.py b/music_assistant/server/helpers/compare.py index 9b3521c2..2bcc628b 100644 --- a/music_assistant/server/helpers/compare.py +++ b/music_assistant/server/helpers/compare.py @@ -242,22 +242,27 @@ def compare_external_ids( """Compare external ids and return True if a match was found.""" for external_id_base in external_ids_base: for external_id_compare in external_ids_compare: - if external_id_compare[0] != external_id_base[0]: + external_id_base_type, external_id_base_value = external_id_base + external_id_compare_type, external_id_compare_value = external_id_compare + if external_id_compare_type != external_id_base_type: continue # handle upc stored as EAN-13 barcode - if external_id_base[0] == ExternalID.BARCODE and len(external_id_base[1]) == 12: - external_id_base[1] = f"0{external_id_base}" - if external_id_compare[1] == ExternalID.BARCODE and len(external_id_compare[1]) == 12: - external_id_compare[1] = f"0{external_id_compare}" - if external_id_base[0] in (ExternalID.ISRC, ExternalID.BARCODE): - if external_id_compare[1] == external_id_base[1]: + if external_id_base_type == ExternalID.BARCODE and len(external_id_base_value) == 12: + external_id_base_value = f"0{external_id_base_value}" + if ( + external_id_compare_value == ExternalID.BARCODE + and len(external_id_compare_value) == 12 + ): + external_id_compare_value = f"0{external_id_compare_value}" + if external_id_base_type in (ExternalID.ISRC, ExternalID.BARCODE): + if external_id_compare_value == external_id_base_value: # barcode and isrc can be multiple per media item # so we only return early on match as there might be # another entry for this ExternalID type. return True continue # other ExternalID types: external id must be exact match. - return external_id_compare[1] == external_id_base[1] + return external_id_compare_value == external_id_base_value # return None to define we did not find the same external id type in both sets return None diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 96cc8c3d..90d065d0 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -18,7 +18,7 @@ from zeroconf import IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo from music_assistant.common.helpers.datetime import utc -from music_assistant.common.helpers.util import empty_queue, get_ip_pton, select_free_port +from music_assistant.common.helpers.util import get_ip_pton, select_free_port from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION, @@ -203,7 +203,6 @@ class AirplayStream: self._audio_reader_task: asyncio.Task | None = None self._cliraop_proc: AsyncProcess | None = None self._ffmpeg_proc: AsyncProcess | None = None - self._buffer = asyncio.Queue(5) async def start(self, start_ntp: int) -> None: """Initialize CLIRaop process for a player.""" @@ -257,14 +256,6 @@ class AirplayStream: # ffmpeg serves as a small buffer towards the realtime cliraop streamer read, write = os.pipe() - async def read_from_buffer() -> AsyncGenerator[bytes, None]: - while True: - next_chunk = await self._buffer.get() - if not next_chunk: - break - yield next_chunk - del next_chunk - ffmpeg_args = get_ffmpeg_args( input_format=self.input_format, output_format=AIRPLAY_PCM_FORMAT, @@ -272,7 +263,7 @@ class AirplayStream: ) self._ffmpeg_proc = AsyncProcess( ffmpeg_args, - stdin=read_from_buffer(), + stdin=True, stdout=write, name="cliraop_ffmpeg", ) @@ -289,7 +280,6 @@ class AirplayStream: async def stop(self, wait: bool = True): """Stop playback and cleanup.""" self.running = False - empty_queue(self._buffer) async def _stop() -> None: # ffmpeg MUST be stopped before cliraop due to the chained pipes @@ -309,11 +299,11 @@ class AirplayStream: async def write_chunk(self, chunk: bytes) -> None: """Write a (pcm) audio chunk to ffmpeg.""" - await self._buffer.put(chunk) + await self._ffmpeg_proc.write(chunk) async def write_eof(self) -> None: """Write EOF to the ffmpeg stdin.""" - await self._buffer.put(b"") + await self._ffmpeg_proc.write_eof() async def send_cli_command(self, command: str) -> None: """Send an interactive command to the running CLIRaop binary.""" diff --git a/music_assistant/server/providers/fully_kiosk/__init__.py b/music_assistant/server/providers/fully_kiosk/__init__.py index bb505641..878ec8d5 100644 --- a/music_assistant/server/providers/fully_kiosk/__init__.py +++ b/music_assistant/server/providers/fully_kiosk/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import time from typing import TYPE_CHECKING @@ -22,7 +23,7 @@ from music_assistant.common.models.enums import ( ) 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 +from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, VERBOSE_LOG_LEVEL from music_assistant.server.models.player_provider import PlayerProvider if TYPE_CHECKING: @@ -101,6 +102,11 @@ class FullyKioskProvider(PlayerProvider): except Exception as err: msg = f"Unable to start the FullyKiosk connection ({err!s}" raise SetupFailedError(msg) from err + # set-up fullykiosk logging + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("fullykiosk").setLevel(logging.DEBUG) + else: + logging.getLogger("fullykiosk").setLevel(self.logger.level + 10) async def loaded_in_mass(self) -> None: """Call after the provider has been loaded.""" diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index 12713ef3..2b1f0c72 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -286,6 +286,7 @@ class AudioDbMetadataProvider(MetadataProvider): except ( aiohttp.client_exceptions.ClientConnectorError, aiohttp.client_exceptions.ServerDisconnectedError, + TimeoutError, ): self.logger.warning("Failed to retrieve %s", endpoint) return None diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 7953fb39..25da5f74 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -151,10 +151,12 @@ class MusicAssistant: await self.streams.setup(await self.config.get_core_config("streams")) # register all api commands (methods with decorator) self._register_api_commands() - # load providers - await self._load_providers() + # load all available providers from manifest files + await self.__load_provider_manifests() # setup discovery self.create_task(self._setup_discovery()) + # load providers + await self._load_providers() async def stop(self) -> None: """Stop running the music assistant server.""" @@ -463,6 +465,16 @@ class MusicAssistant: self.config.set(f"{CONF_PROVIDERS}/{conf.instance_id}/last_error", None) self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers()) await self._update_available_providers_cache() + # run initial discovery after load + for mdns_type in provider.manifest.mdns_discovery or []: + for mdns_name in set(self.aiozc.zeroconf.cache.cache): + if mdns_type not in mdns_name or mdns_type == mdns_name: + continue + info = AsyncServiceInfo(mdns_type, mdns_name) + if await info.async_request(self.aiozc.zeroconf, 3000): + await provider.on_mdns_service_state_change( + mdns_name, ServiceStateChange.Added, info + ) # if this is a music provider, start sync if provider.type == ProviderType.MUSIC: self.music.start_sync(providers=[provider.instance_id]) @@ -512,9 +524,6 @@ class MusicAssistant: async def _load_providers(self) -> None: """Load providers from config.""" - # load all available providers from manifest files - await self.__load_provider_manifests() - # create default config for any 'load_by_default' providers (e.g. URL provider) for prov_manifest in self._provider_manifests.values(): if not prov_manifest.load_by_default: -- 2.34.1