Skip to content

Commit

Permalink
Several small bugfixes (#1348)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Jun 11, 2024
1 parent c08c6b9 commit ce360d9
Show file tree
Hide file tree
Showing 25 changed files with 205 additions and 95 deletions.
10 changes: 8 additions & 2 deletions music_assistant/common/models/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
from enum import Enum
Expand Down Expand Up @@ -39,6 +40,10 @@

from .enums import ConfigEntryType

# TEMP: ignore UserWarnings from mashumaro
# https://github.com/Fatal1ty/mashumaro/issues/221
warnings.filterwarnings("ignore", category=UserWarning, module="mashumaro")

LOGGER = logging.getLogger(__name__)

ENCRYPT_CALLBACK: callable[[str], str] | None = None
Expand Down Expand Up @@ -343,6 +348,7 @@ class CoreConfig(Config):
label=CONF_FLOW_MODE,
default_value=True,
value=True,
hidden=True,
)

CONF_ENTRY_AUTO_PLAY = ConfigEntry(
Expand Down Expand Up @@ -385,7 +391,7 @@ class CoreConfig(Config):
label="Target level for volume normalization",
description="Adjust average (perceived) loudness to this target level",
depends_on=CONF_VOLUME_NORMALIZATION,
category="audio",
category="advanced",
)

CONF_ENTRY_EQ_BASS = ConfigEntry(
Expand Down Expand Up @@ -447,7 +453,7 @@ class CoreConfig(Config):
label="Crossfade duration",
description="Duration in seconds of the crossfade between tracks (if enabled)",
depends_on=CONF_CROSSFADE,
category="audio",
category="advanced",
)

CONF_ENTRY_HIDE_PLAYER = ConfigEntry(
Expand Down
15 changes: 10 additions & 5 deletions music_assistant/server/controllers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ async def get_provider_config(self, instance_id: str) -> ProviderConfig:
async def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType:
"""Return single configentry value for a provider."""
cache_key = f"prov_conf_value_{instance_id}.{key}"
if cached_value := self._value_cache.get(cache_key) is not None:
if (cached_value := self._value_cache.get(cache_key)) is not None:
return cached_value
conf = await self.get_provider_config(instance_id)
val = (
Expand Down Expand Up @@ -339,12 +339,17 @@ async def get_player_configs(
async def get_player_config(self, player_id: str) -> PlayerConfig:
"""Return (full) configuration for a single player."""
if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
if prov := self.mass.get_provider(raw_conf["provider"]):
if player := self.mass.players.get(player_id, False):
raw_conf["default_name"] = player.display_name
raw_conf["provider"] = player.provider
prov = self.mass.get_provider(player.provider)
conf_entries = await prov.get_player_config_entries(player_id)
if player := self.mass.players.get(player_id, False):
raw_conf["default_name"] = player.display_name
else:
conf_entries = ()
# handle unavailable player and/or provider
if prov := self.mass.get_provider(raw_conf["provider"]):
conf_entries = await prov.get_player_config_entries(player_id)
else:
conf_entries = ()
raw_conf["available"] = False
raw_conf["name"] = raw_conf.get("name")
raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
Expand Down
28 changes: 28 additions & 0 deletions music_assistant/server/controllers/media/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from music_assistant.constants import (
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_PLAYLOG,
DB_TABLE_PROVIDER_MAPPINGS,
MASS_LOGGER_NAME,
)
Expand Down Expand Up @@ -167,6 +168,24 @@ async def remove_item_from_library(self, item_id: str | int) -> None:
DB_TABLE_PROVIDER_MAPPINGS,
{"media_type": self.media_type.value, "item_id": db_id},
)
# cleanup playlog table
await self.mass.music.database.delete(
DB_TABLE_PLAYLOG,
{
"media_type": self.media_type.value,
"item_id": db_id,
"provider": "library",
},
)
for prov_mapping in library_item.provider_mappings:
await self.mass.music.database.delete(
DB_TABLE_PLAYLOG,
{
"media_type": self.media_type.value,
"item_id": prov_mapping.item_id,
"provider": prov_mapping.provider_instance,
},
)
# NOTE: this does not delete any references to this item in other records,
# this is handled/overridden in the mediatype specific controllers
self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, library_item.uri, library_item)
Expand Down Expand Up @@ -598,6 +617,15 @@ async def remove_provider_mapping(
"provider_item_id": provider_item_id,
},
)
# cleanup playlog table
await self.mass.music.database.delete(
DB_TABLE_PLAYLOG,
{
"media_type": self.media_type.value,
"item_id": provider_item_id,
"provider": provider_instance_id,
},
)
if library_item.provider_mappings:
# we (temporary?) duplicate the provider mappings in a separate column of the media
# item's table, because the json_group_array query is superslow
Expand Down
15 changes: 12 additions & 3 deletions music_assistant/server/controllers/media/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,15 @@ async def tracks(
final_tracks.append(track)
else:
final_tracks = tracks
# we set total to None as we have no idea how many tracks there are
# the frontend can figure this out and stop paging when it gets an empty list
return PagedItems(items=final_tracks, limit=limit, offset=offset, total=None)
# We set total to None as we have no idea how many tracks there are.
# The frontend can figure this out and stop paging when it gets an empty list.
# Exception is when we receive a result that is either much higher
# or smaller than the limit - in that case we consider the list final.
total = None
count = len(final_tracks)
if count and (count < (limit - 10) or count > (limit + 10)):
total = offset + len(final_tracks)
return PagedItems(items=final_tracks, limit=limit, offset=offset, total=total, count=count)

async def create_playlist(
self, name: str, provider_instance_or_domain: str | None = None
Expand Down Expand Up @@ -305,6 +311,9 @@ async def get_all_playlist_tracks(
break
if paged_items.count == 0:
break
if paged_items.total is None and paged_items.items == result:
# safety guard for malfunctioning provider
break
offset += paged_items.count
return result

Expand Down
42 changes: 41 additions & 1 deletion music_assistant/server/controllers/music.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ async def mark_item_played(
prov_key = provider_instance_id_or_domain

# do not try to store dynamic urls (e.g. with auth token etc.),
# stick with plaun uri/urls only
# stick with plain uri/urls only
if "http" in item_id and "?" in item_id:
return

Expand Down Expand Up @@ -730,6 +730,9 @@ def on_sync_task_done(task: asyncio.Task) -> None:
else:
self.logger.info("Sync task for %s completed", provider.name)
self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs)
# schedule db cleanup after sync
if not self.in_progress_syncs:
self.mass.create_task(self._cleanup_database())

task.add_done_callback(on_sync_task_done)

Expand Down Expand Up @@ -794,6 +797,14 @@ async def cleanup_provider(self, provider_instance: str) -> None:
if remaining_items_count := await self.database.get_count_from_query(query):
errors += remaining_items_count

# cleanup playlog table
await self.mass.music.database.delete(
DB_TABLE_PLAYLOG,
{
"provider": provider_instance,
},
)

if errors == 0:
# cleanup successful, remove from the deleted_providers setting
self.logger.info("Provider %s removed from library", provider_instance)
Expand All @@ -814,6 +825,35 @@ def _schedule_sync(self) -> None:
# NOTE: sync_interval is stored in minutes, we need seconds
self.mass.loop.call_later(sync_interval * 60, self._schedule_sync)

async def _cleanup_database(self) -> None:
"""Perform database cleanup/maintenance."""
self.logger.debug("Performing database cleanup...")
# Remove playlog entries older than 90 days
await self.database.delete_where_query(
DB_TABLE_PLAYLOG, f"timestamp < strftime('%s','now') - {3600 * 24 * 90}"
)
# db tables cleanup
for ctrl in (self.albums, self.artists, self.tracks, self.playlists, self.radio):
# Provider mappings where the db item is removed
query = (
f"item_id not in (SELECT item_id from {ctrl.db_table}) "
f"AND media_type = '{ctrl.media_type}'"
)
await self.database.delete_where_query(DB_TABLE_PROVIDER_MAPPINGS, query)
# Orphaned db items
query = (
f"item_id not in (SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
f"WHERE media_type = '{ctrl.media_type}')"
)
await self.database.delete_where_query(ctrl.db_table, query)
# Cleanup removed db items from the playlog
where_clause = (
f"media_type = '{ctrl.media_type}' AND provider = 'library' "
f"AND item_id not in (select item_id from {ctrl.db_table})"
)
await self.mass.music.database.delete_where_query(DB_TABLE_PLAYLOG, where_clause)
self.logger.debug("Database cleanup done")

async def _setup_database(self) -> None:
"""Initialize database."""
db_path = os.path.join(self.mass.storage_path, "library.db")
Expand Down
13 changes: 7 additions & 6 deletions music_assistant/server/controllers/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -
"Player %s does not support (un)sync commands", child_player.name
)
continue
if child_player.synced_to and child_player.synced_to == target_player:
continue # already synced to this target
if child_player.synced_to and child_player.synced_to != target_player:
# player already synced to another player, unsync first
self.logger.warning(
Expand All @@ -620,19 +622,16 @@ async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -
continue
# if we reach here, all checks passed
final_player_ids.append(child_player_id)
# set active source if player is synced
child_player.active_source = parent_player.active_source

# forward command to the player provider after all (base) sanity checks
player_provider = self.get_player_provider(target_player)
await player_provider.cmd_sync_many(target_player, child_player_ids)

@api_command("players/cmd/unsync_many")
async def cmd_unsync_many(self, player_ids: list[str]) -> None:
"""Handle UNSYNC command for all the given players.
Remove the given player from any syncgroups it currently is synced to.
- player_id: player_id of the player to handle the command.
"""
"""Handle UNSYNC command for all the given players."""
# filter all player ids on compatibility and availability
final_player_ids: UniqueList[str] = UniqueList()
for player_id in player_ids:
Expand All @@ -645,6 +644,8 @@ async def cmd_unsync_many(self, player_ids: list[str]) -> None:
)
continue
final_player_ids.append(player_id)
# reset active source player if is unsynced
child_player.active_source = None

if not final_player_ids:
return
Expand Down
6 changes: 6 additions & 0 deletions music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
get_hls_stream,
get_icy_stream,
get_player_filter_params,
get_silence,
parse_loudnorm,
strip_silence,
)
Expand Down Expand Up @@ -738,6 +739,11 @@ async def get_media_stream(
)
elif streamdetails.stream_type == StreamType.ICY:
audio_source = get_icy_stream(self.mass, streamdetails.path, streamdetails)
# pad some silence before the radio stream starts to create some headroom
# for radio stations that do not provide any look ahead buffer
# without this, some radio streams jitter a lot
async for chunk in get_silence(2, pcm_format):
yield chunk
elif streamdetails.stream_type == StreamType.HLS:
audio_source = get_hls_stream(
self.mass, streamdetails.path, streamdetails, streamdetails.seek_position
Expand Down
48 changes: 35 additions & 13 deletions music_assistant/server/providers/airplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
CONF_ALAC_ENCODE = "alac_encode"
CONF_VOLUME_START = "volume_start"
CONF_PASSWORD = "password"

CONF_BIND_INTERFACE = "bind_interface"

PLAYER_CONFIG_ENTRIES = (
CONF_ENTRY_FLOW_MODE_ENFORCED,
Expand Down Expand Up @@ -138,7 +138,16 @@ async def get_config_entries(
values: the (intermediate) raw values for config entries sent with the action.
"""
# ruff: noqa: ARG001
return () # we do not have any config entries (yet)
return (
ConfigEntry(
key=CONF_BIND_INTERFACE,
type=ConfigEntryType.STRING,
default_value=mass.streams.publish_ip,
label="Bind interface",
description="Interface to bind to for Airplay streaming.",
category="advanced",
),
)


def convert_airplay_volume(value: float) -> int:
Expand Down Expand Up @@ -216,6 +225,10 @@ async def start(self, start_ntp: int, wait_start: int = 1000) -> None:
extra_args = []
player_id = self.airplay_player.player_id
mass_player = self.mass.players.get(player_id)
bind_ip = await self.mass.config.get_provider_config_value(
self.prov.instance_id, CONF_BIND_INTERFACE
)
extra_args += ["-if", bind_ip]
if self.mass.config.get_raw_player_config_value(player_id, CONF_ENCRYPTION, False):
extra_args += ["-encrypt"]
if self.mass.config.get_raw_player_config_value(player_id, CONF_ALAC_ENCODE, True):
Expand Down Expand Up @@ -282,19 +295,20 @@ async def stop(self):
"""Stop playback and cleanup."""
if self._stopped:
return
if not self._cliraop_proc.closed:
if self._cliraop_proc.proc and not self._cliraop_proc.closed:
await self.send_cli_command("ACTION=STOP")
self._stopped = True # set after send_cli command!
if self.audio_source_task and not self.audio_source_task.done():
self.audio_source_task.cancel()
try:
await asyncio.wait_for(self._cliraop_proc.wait(), 5)
except TimeoutError:
self.prov.logger.warning(
"Raop process for %s did not stop in time, is the player offline?",
self.airplay_player.player_id,
)
await self._cliraop_proc.close(True)
if self._cliraop_proc.proc:
try:
await asyncio.wait_for(self._cliraop_proc.wait(), 5)
except TimeoutError:
self.prov.logger.warning(
"Raop process for %s did not stop in time, is the player offline?",
self.airplay_player.player_id,
)
await self._cliraop_proc.close(True)

# ffmpeg can sometimes hang due to the connected pipes
# we handle closing it but it can be a bit slow so do that in the background
Expand Down Expand Up @@ -598,7 +612,7 @@ async def cmd_pause(self, player_id: str) -> None:
# prefer interactive command to our streamer
tg.create_task(airplay_player.active_stream.send_cli_command("ACTION=PAUSE"))

async def play_media(
async def play_media( # noqa: PLR0915
self,
player_id: str,
media: PlayerMedia,
Expand Down Expand Up @@ -628,8 +642,16 @@ async def play_media(
ugp_stream = ugp_provider.streams[media.queue_id]
input_format = ugp_stream.audio_format
audio_source = ugp_stream.subscribe_raw()
elif media.media_type == MediaType.RADIO and media.queue_id and media.queue_item_id:
# radio stream - consume media stream directly
input_format = AIRPLAY_PCM_FORMAT
queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id)
audio_source = self.mass.streams.get_media_stream(
streamdetails=queue_item.streamdetails,
pcm_format=AIRPLAY_PCM_FORMAT,
)
elif media.queue_id and media.queue_item_id:
# regular queue stream request
# regular queue (flow) stream request
input_format = AIRPLAY_PCM_FORMAT
audio_source = self.mass.streams.get_flow_stream(
queue=self.mass.player_queues.get(media.queue_id),
Expand Down
Binary file modified music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64
100755 → 100644
Binary file not shown.
Binary file not shown.
Binary file modified music_assistant/server/providers/airplay/bin/cliraop-macos-arm64
Binary file not shown.
4 changes: 4 additions & 0 deletions music_assistant/server/providers/apple_music/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ async def _get_data(self, endpoint, **kwargs) -> dict[str, Any]:
# Convert HTTP errors to exceptions
if response.status == 404:
raise MediaNotFoundError(f"{endpoint} not found")
if response.status == 504:
# See if we can get more info from the response on occasional timeouts
self.logger.debug("Apple Music API Timeout: %s", response.json(loads=json_loads))
raise ResourceTemporarilyUnavailable("Apple Music API Timeout")
if response.status == 429:
# Debug this for now to see if the response headers give us info about the
# backoff time. There is no documentation on this.
Expand Down
Loading

0 comments on commit ce360d9

Please sign in to comment.