Some small bugfixes (#1205)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 5 Apr 2024 15:10:14 +0000 (17:10 +0200)
committerGitHub <noreply@github.com>
Fri, 5 Apr 2024 15:10:14 +0000 (17:10 +0200)
music_assistant/common/models/enums.py
music_assistant/common/models/media_items.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/streams.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/compare.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/fully_kiosk/__init__.py
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/server.py

index 2cfbaaa2841272f598d58d03ca1cfa1fb9788509..be6991585320c15b70c787251c468c35c49bed5f 100644 (file)
@@ -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"
index 577b879b78496747648da7265e7ed40b1f254cf3..8edb7bedd0e57fb57e03289138671209f8daade2 100644 (file)
@@ -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."""
index fc376a36913334870536cf7255bb4feb7eeff43c..f9c33287c2f7397a3e97eeecf25df489bfe7a120 100644 (file)
@@ -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)
index f608609faeda1fe46c45601e9e1688ae2c055bc6..85eddb9eb8d2c67a90d7ef3012d8c229e95f810b 100644 (file)
@@ -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 = []
index 02397131936fe5dda136c9c9749b9dc5608c0883..fb0901683074ea9b0b9b27fd108d739a02f750b4 100644 (file)
@@ -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"],
index 9b3521c224b914438fce47beec3d11c302328789..2bcc628ba90e8fad7fc5ad1c86f6c26b4e583c4b 100644 (file)
@@ -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
 
index 96cc8c3dd025ff37068385584870684cba63dd9d..90d065d0ab6d172897c635988deaa4113597156e 100644 (file)
@@ -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."""
index bb5056411358304e897f420f6d10dac3fdcc7c16..878ec8d5ca094a383f757e9e63aa125d7b56cd8a 100644 (file)
@@ -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."""
index 12713ef3fafeff5c9992e2da032db1e83ecd6c05..2b1f0c721d499dba5be7463800c6c3bd32ef6c21 100644 (file)
@@ -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
index 7953fb391001c507c6ed560911ce2ee4b768a8e6..25da5f74ab5db00cfda23702c2b533d06e78c459 100644 (file)
@@ -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: