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"
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)
@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."""
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)
get_icy_stream,
get_player_filter_params,
parse_loudnorm,
- resolve_radio_stream,
strip_silence,
)
from music_assistant.server.helpers.util import get_ips
"""
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
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 = []
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,
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
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]
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:
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"))
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
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:
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"],
"""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
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,
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."""
# 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,
)
self._ffmpeg_proc = AsyncProcess(
ffmpeg_args,
- stdin=read_from_buffer(),
+ stdin=True,
stdout=write,
name="cliraop_ffmpeg",
)
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
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."""
from __future__ import annotations
import asyncio
+import logging
import time
from typing import TYPE_CHECKING
)
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:
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."""
except (
aiohttp.client_exceptions.ClientConnectorError,
aiohttp.client_exceptions.ServerDisconnectedError,
+ TimeoutError,
):
self.logger.warning("Failed to retrieve %s", endpoint)
return None
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."""
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])
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: