Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More improvements for the Airplay provider #1100

Merged
merged 2 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions music_assistant/common/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class ProviderManifest(DataClassORJSONMixin):
# if this attribute is omitted and an icon_dark.svg is found in the provider
# folder, the file contents will be read instead.
icon_svg_dark: str | None = None
# mdns_discovery: list of mdns types to discover
mdns_discovery: list[str] | None = None

@classmethod
async def parse(cls: ProviderManifest, manifest_file: str) -> ProviderManifest:
Expand Down
28 changes: 21 additions & 7 deletions music_assistant/server/controllers/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,42 +330,56 @@ async def get_image_url_for_item(
return None

def get_image_url(
self, image: MediaItemImage, size: int = 0, prefer_proxy: bool = False
self,
image: MediaItemImage,
size: int = 0,
prefer_proxy: bool = False,
image_format: str = "png",
) -> str:
"""Get (proxied) URL for MediaItemImage."""
if image.provider != "url" or prefer_proxy or size:
# return imageproxy url for images that need to be resolved
# the original path is double encoded
encoded_url = urllib.parse.quote(urllib.parse.quote(image.path))
return f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}" # noqa: E501
return f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}&fmt={image_format}" # noqa: E501
return image.path

async def get_thumbnail(
self, path: str, size: int | None = None, provider: str = "url", base64: bool = False
self,
path: str,
size: int | None = None,
provider: str = "url",
base64: bool = False,
image_format: str = "png",
) -> bytes | str:
"""Get/create thumbnail image for path (image url or local path)."""
thumbnail = await get_image_thumb(self.mass, path, size=size, provider=provider)
thumbnail = await get_image_thumb(
self.mass, path, size=size, provider=provider, image_format=image_format
)
if base64:
enc_image = b64encode(thumbnail).decode()
thumbnail = f"data:image/png;base64,{enc_image}"
thumbnail = f"data:image/{image_format};base64,{enc_image}"
return thumbnail

async def handle_imageproxy(self, request: web.Request) -> web.Response:
"""Handle request for image proxy."""
path = request.query["path"]
provider = request.query.get("provider", "url")
size = int(request.query.get("size", "0"))
image_format = request.query.get("fmt", "png")
if "%" in path:
# assume (double) encoded url, decode it
path = urllib.parse.unquote(path)

with suppress(FileNotFoundError):
image_data = await self.get_thumbnail(path, size=size, provider=provider)
image_data = await self.get_thumbnail(
path, size=size, provider=provider, image_format=image_format
)
# we set the cache header to 1 year (forever)
# the client can use the checksum value to refresh when content changes
return web.Response(
body=image_data,
headers={"Cache-Control": "max-age=31536000"},
content_type="image/png",
content_type=f"image/{image_format}",
)
return web.Response(status=404)
80 changes: 41 additions & 39 deletions music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
ConfigValueOption,
ConfigValueType,
)
from music_assistant.common.models.enums import ConfigEntryType, ContentType
from music_assistant.common.models.enums import ConfigEntryType, ContentType, MediaType
from music_assistant.common.models.errors import MediaNotFoundError, QueueEmpty
from music_assistant.common.models.media_items import AudioFormat
from music_assistant.constants import (
Expand Down Expand Up @@ -515,7 +515,7 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
# feed stdin with pcm audio chunks from origin
async def read_audio() -> None:
try:
async for chunk in get_media_stream(
async for _, chunk in get_media_stream(
self.mass,
streamdetails=queue_item.streamdetails,
pcm_format=pcm_format,
Expand Down Expand Up @@ -777,6 +777,8 @@ async def get_flow_stream(
use_crossfade = self.mass.config.get_raw_player_config_value(
queue.queue_id, CONF_CROSSFADE, False
)
if start_queue_item.media_type != MediaType.TRACK:
use_crossfade = False
pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
self.logger.info(
"Start Queue Flow stream for Queue %s - crossfade: %s",
Expand Down Expand Up @@ -814,12 +816,13 @@ async def get_flow_stream(
)
crossfade_size = int(pcm_sample_size * crossfade_duration)
queue_track.streamdetails.seconds_skipped = seek_position
buffer_size = crossfade_size if use_crossfade else int(pcm_sample_size * 2)
buffer_size = int(pcm_sample_size * 2) # 2 seconds
if use_crossfade:
buffer_size += crossfade_size
bytes_written = 0
buffer = b""
chunk_num = 0
# handle incoming audio chunks
async for chunk in get_media_stream(
async for is_last_chunk, chunk in get_media_stream(
self.mass,
queue_track.streamdetails,
pcm_format=pcm_format,
Expand All @@ -829,23 +832,24 @@ async def get_flow_stream(
strip_silence_begin=use_crossfade,
strip_silence_end=use_crossfade,
):
chunk_num += 1

# throttle buffer, do not allow more than 30 seconds in buffer
# throttle buffer, do not allow more than 30 seconds in player's own buffer
seconds_buffered = (total_bytes_written + bytes_written) / pcm_sample_size
player = self.mass.players.get(queue.queue_id)
if seconds_buffered > 60 and player.corrected_elapsed_time > 30:
while (seconds_buffered - player.corrected_elapsed_time) > 30:
await asyncio.sleep(1)

#### HANDLE FIRST PART OF TRACK
# ALWAYS APPEND CHUNK TO BUFFER
buffer += chunk
if not is_last_chunk and len(buffer) < buffer_size:
# buffer is not full enough, move on
continue

# buffer full for crossfade
if last_fadeout_part and (len(buffer) >= buffer_size):
first_part = buffer + chunk
#### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
if not is_last_chunk and last_fadeout_part:
# perform crossfade
fadein_part = first_part[:crossfade_size]
remaining_bytes = first_part[crossfade_size:]
fadein_part = buffer[:crossfade_size]
remaining_bytes = buffer[crossfade_size:]
crossfade_part = await crossfade_pcm_parts(
fadein_part,
last_fadeout_part,
Expand All @@ -855,39 +859,37 @@ async def get_flow_stream(
# send crossfade_part
yield crossfade_part
bytes_written += len(crossfade_part)
# also write the leftover bytes from the strip action
# also write the leftover bytes from the crossfade action
if remaining_bytes:
yield remaining_bytes
bytes_written += len(remaining_bytes)

del remaining_bytes
# clear vars
last_fadeout_part = b""
buffer = b""
continue

# enough data in buffer, feed to output
if len(buffer) >= (buffer_size * 2):
yield buffer[:buffer_size]
bytes_written += buffer_size
buffer = buffer[buffer_size:] + chunk
continue
#### HANDLE END OF TRACK
elif is_last_chunk:
if use_crossfade:
# if crossfade is enabled, save fadeout part to pickup for next track
last_fadeout_part = buffer[-crossfade_size:]
remaining_bytes = buffer[:-crossfade_size]
yield remaining_bytes
bytes_written += len(remaining_bytes)
del remaining_bytes
else:
# no crossfade enabled, just yield the (entire) buffer last part
yield buffer
bytes_written += len(buffer)
# clear vars
buffer = b""

# all other: fill buffer
buffer += chunk
continue

#### HANDLE END OF TRACK

if buffer and use_crossfade:
# if crossfade is enabled, save fadeout part to pickup for next track
last_fadeout_part = buffer[-crossfade_size:]
remaining_bytes = buffer[:-crossfade_size]
yield remaining_bytes
bytes_written += len(remaining_bytes)
elif buffer:
# no crossfade enabled, just yield the buffer last part
yield buffer
bytes_written += len(buffer)
#### OTHER: enough data in buffer, feed to output
else:
chunk_size = len(chunk)
yield buffer[:chunk_size]
bytes_written += chunk_size
buffer = buffer[chunk_size:]

# update duration details based on the actual pcm data we sent
# this also accounts for crossfade and silence stripping
Expand Down
14 changes: 7 additions & 7 deletions music_assistant/server/helpers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,8 @@ async def get_media_stream( # noqa: PLR0915
seek_position: int = 0,
fade_in: bool = False,
strip_silence_begin: bool = False,
strip_silence_end: bool = True,
) -> AsyncGenerator[bytes, None]:
strip_silence_end: bool = False,
) -> AsyncGenerator[tuple[bool, bytes], None]:
"""
Get the (raw PCM) audio stream for the given streamdetails.

Expand Down Expand Up @@ -458,7 +458,7 @@ async def writer() -> None:
sample_rate=pcm_format.sample_rate,
bit_depth=pcm_format.bit_depth,
)
yield stripped_audio
yield (False, stripped_audio)
bytes_sent += len(stripped_audio)
prev_chunk = b""
del stripped_audio
Expand All @@ -470,7 +470,7 @@ async def writer() -> None:

# middle part of the track, send previous chunk and collect current chunk
if prev_chunk:
yield prev_chunk
yield (False, prev_chunk)
bytes_sent += len(prev_chunk)

prev_chunk = chunk
Expand All @@ -484,11 +484,11 @@ async def writer() -> None:
bit_depth=pcm_format.bit_depth,
reverse=True,
)
yield stripped_audio
yield (True, stripped_audio)
bytes_sent += len(stripped_audio)
del stripped_audio
else:
yield prev_chunk
yield (True, prev_chunk)
bytes_sent += len(prev_chunk)

del prev_chunk
Expand Down Expand Up @@ -551,7 +551,7 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo
LOGGER.debug("Error while parsing radio URL %s: %s", url, err)

result = (url, supports_icy)
await mass.cache.set(cache_key, result)
await mass.cache.set(cache_key, result, expiration=86400)
return result


Expand Down
8 changes: 6 additions & 2 deletions music_assistant/server/helpers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str =


async def get_image_thumb(
mass: MusicAssistant, path_or_url: str, size: int | None, provider: str = "url"
mass: MusicAssistant,
path_or_url: str,
size: int | None,
provider: str = "url",
image_format: str = "PNG",
) -> bytes:
"""Get (optimized) PNG thumbnail from image url."""
img_data = await get_image_data(mass, path_or_url, provider)
Expand All @@ -45,7 +49,7 @@ def _create_image():
img = Image.open(BytesIO(img_data))
if size:
img.thumbnail((size, size), Image.LANCZOS) # pylint: disable=no-member
img.convert("RGB").save(data, "PNG", optimize=True)
img.convert("RGB").save(data, image_format, optimize=True)
return data.getvalue()

return await asyncio.to_thread(_create_image)
Expand Down
10 changes: 9 additions & 1 deletion music_assistant/server/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME

if TYPE_CHECKING:
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceInfo

from music_assistant.common.models.config_entries import ProviderConfig
from music_assistant.common.models.enums import ProviderFeature, ProviderType
from music_assistant.common.models.provider import ProviderInstance, ProviderManifest
Expand All @@ -25,7 +28,7 @@ def __init__(
self.manifest = manifest
self.config = config
mass_logger = logging.getLogger(ROOT_LOGGER_NAME)
self.logger = mass_logger.getChild(f"providers.{self.instance_id}")
self.logger = mass_logger.getChild(f"providers.{self.domain}")
log_level = config.get_value(CONF_LOG_LEVEL)
if log_level == "GLOBAL":
self.logger.setLevel(mass_logger.level)
Expand Down Expand Up @@ -61,6 +64,11 @@ async def unload(self) -> None:
Called when provider is deregistered (e.g. MA exiting or config reloading).
"""

async def on_mdns_service_state_change(
self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
) -> None:
"""Handle MDNS service state callback."""

@property
def type(self) -> ProviderType:
"""Return type of this provider."""
Expand Down
Loading