Skip to content

Commit

Permalink
Merge branch 'main' into btoconnor.siriusxm_stream_title
Browse files Browse the repository at this point in the history
  • Loading branch information
btoconnor authored Oct 22, 2024
2 parents fa9877c + 7653499 commit 4479cd7
Show file tree
Hide file tree
Showing 20 changed files with 188 additions and 151 deletions.
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ repos:
types: [python]
entry: scripts/run-in-env.sh ruff check --fix
require_serial: true
stages: [commit, push, manual]
stages: [pre-commit, pre-push, manual]
- id: ruff-format
name: 🐶 Ruff Formatter
language: system
types: [python]
entry: scripts/run-in-env.sh ruff format
require_serial: true
stages: [commit, push, manual]
stages: [pre-commit, pre-push, manual]
- id: check-ast
name: 🐍 Check Python AST
language: system
Expand All @@ -34,7 +34,7 @@ repos:
language: system
types: [text, executable]
entry: scripts/run-in-env.sh check-executables-have-shebangs
stages: [commit, push, manual]
stages: [pre-commit, pre-push, manual]
- id: check-json
name: { Check JSON files
language: system
Expand Down Expand Up @@ -71,7 +71,7 @@ repos:
language: system
types: [text]
entry: scripts/run-in-env.sh end-of-file-fixer
stages: [commit, push, manual]
stages: [pre-commit, pre-push, manual]
- id: no-commit-to-branch
name: 🛑 Don't commit to main branch
language: system
Expand All @@ -85,7 +85,7 @@ repos:
language: system
types: [text]
entry: scripts/run-in-env.sh trailing-whitespace-fixer
stages: [commit, push, manual]
stages: [pre-commit, pre-push, manual]
- id: mypy
name: mypy
entry: scripts/run-in-env.sh mypy
Expand Down
1 change: 0 additions & 1 deletion music_assistant/common/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,6 @@ class StreamType(StrEnum):
HTTP = "http" # regular http stream
ENCRYPTED_HTTP = "encrypted_http" # encrypted http stream
HLS = "hls" # http HLS stream
ENCRYPTED_HLS = "encrypted_hls" # encrypted HLS stream
ICY = "icy" # http stream with icy metadata
LOCAL_FILE = "local_file"
CUSTOM = "custom"
Expand Down
15 changes: 13 additions & 2 deletions music_assistant/common/models/media_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class AudioFormat(DataClassDictMixin):
bit_depth: int = 16
channels: int = 2
output_format_str: str = ""
bit_rate: int = 320 # optional
bit_rate: int | None = None # optional bitrate in kbps

def __post_init__(self) -> None:
"""Execute actions after init."""
Expand All @@ -70,6 +70,9 @@ def __post_init__(self) -> None:
)
elif not self.output_format_str:
self.output_format_str = self.content_type.value
if self.bit_rate and self.bit_rate > 100000:
# correct bit rate in bits per second to kbps
self.bit_rate = int(self.bit_rate / 1000)

@property
def quality(self) -> int:
Expand All @@ -80,7 +83,8 @@ def quality(self) -> int:
# lossy content, bit_rate is most important score
# but prefer some codecs over others
# calculate a rough score based on bit rate per channel
bit_rate_score = (self.bit_rate / self.channels) / 100
bit_rate = self.bit_rate or 320
bit_rate_score = (bit_rate / self.channels) / 100
if self.content_type in (ContentType.AAC, ContentType.OGG):
bit_rate_score += 1
return int(bit_rate_score)
Expand All @@ -96,6 +100,13 @@ def __eq__(self, other: object) -> bool:
return False
return self.output_format_str == other.output_format_str

def __post_serialize__(self, d: dict[Any, Any]) -> dict[Any, Any]:
"""Execute action(s) on serialization."""
# bit_rate is now optional. Set default value to keep compatibility
# TODO: remove this after release of MA 2.5
d["bit_rate"] = d["bit_rate"] or 0
return d


@dataclass(kw_only=True)
class ProviderMapping(DataClassDictMixin):
Expand Down
56 changes: 24 additions & 32 deletions music_assistant/server/controllers/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,30 +492,13 @@ async def play_announcement(
player_id,
CONF_TTS_PRE_ANNOUNCE,
)
if not native_announce_support and player.active_group:
for group_member in self.iter_group_members(player, True, True):
if PlayerFeature.PLAY_ANNOUNCEMENT in group_member.supported_features:
native_announce_support = True
break
# redirect to group player if playergroup is active
self.logger.warning(
"Detected announcement request to a player which has a group active, "
"this will be redirected to the group."
)
await self.play_announcement(
player.active_group, url, use_pre_announce, volume_level
)
return

# if player type is group with all members supporting announcements
# or if the groupplayer is not powered, we forward the request to each individual player
# if player type is group with all members supporting announcements,
# we forward the request to each individual player
if player.type == PlayerType.GROUP and (
all(
x
PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features
for x in self.iter_group_members(player)
if PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features
)
or not player.powered
):
# forward the request to each individual player
async with TaskManager(self.mass) as tg:
Expand All @@ -529,7 +512,6 @@ async def play_announcement(
)
)
return

self.logger.info(
"Playback announcement to player %s (with pre-announce: %s): %s",
player.display_name,
Expand Down Expand Up @@ -703,7 +685,7 @@ async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -
# forward command to the player provider after all (base) sanity checks
player_provider = self.get_player_provider(target_player)
async with self._player_throttlers[target_player]:
await player_provider.cmd_sync_many(target_player, child_player_ids)
await player_provider.cmd_sync_many(target_player, final_player_ids)

@api_command("players/cmd/unsync_many")
async def cmd_unsync_many(self, player_ids: list[str]) -> None:
Expand Down Expand Up @@ -1095,14 +1077,14 @@ async def _play_announcement(
- restore the previous power and volume
- restore playback (if needed and if possible)
This default implementation will only be used if the player's
provider has no native support for the PLAY_ANNOUNCEMENT feature.
This default implementation will only be used if the player
(provider) has no native support for the PLAY_ANNOUNCEMENT feature.
"""
prev_power = player.powered
prev_state = player.state
prev_synced_to = player.synced_to
queue = self.mass.player_queues.get_active_queue(player.player_id)
prev_queue_active = queue.active
queue = self.mass.player_queues.get(player.active_source)
prev_queue_active = queue and queue.active
prev_item_id = player.current_item_id
# unsync player if its currently synced
if prev_synced_to:
Expand All @@ -1128,13 +1110,23 @@ async def _play_announcement(
for volume_player_id in player.group_childs or (player.player_id,):
if not (volume_player := self.get(volume_player_id)):
continue
# filter out players that have a different source active
if volume_player.active_source not in (
player.active_source,
volume_player.player_id,
None,
# catch any players that have a different source active
if (
volume_player.active_source
not in (
player.active_source,
volume_player.player_id,
None,
)
and volume_player.state == PlayerState.PLAYING
):
continue
self.logger.warning(
"Detected announcement to playergroup %s while group member %s is playing "
"other content, this may lead to unexpected behavior.",
player.display_name,
volume_player.display_name,
)
tg.create_task(self.cmd_stop(volume_player.player_id))
prev_volume = volume_player.volume_level
announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
temp_volume = announcement_volume or player.volume_level
Expand Down
68 changes: 42 additions & 26 deletions music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,8 @@
"Server": "Music Assistant",
"transferMode.dlna.org": "Streaming",
"contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", # noqa: E501
"Cache-Control": "no-cache,must-revalidate",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Accept-Ranges": "none",
"Connection": "close",
}
ICY_HEADERS = {
"icy-name": "Music Assistant",
Expand Down Expand Up @@ -325,24 +323,24 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
default_sample_rate=queue_item.streamdetails.audio_format.sample_rate,
default_bit_depth=queue_item.streamdetails.audio_format.bit_depth,
)
http_profile: str = await self.mass.config.get_player_config_value(
queue_id, CONF_HTTP_PROFILE
)

# prepare request, add some DLNA/UPNP compatible headers
headers = {
**DEFAULT_STREAM_HEADERS,
"Content-Type": f"audio/{output_format.output_format_str}",
"icy-name": queue_item.name,
}
resp = web.StreamResponse(
status=200,
reason="OK",
headers=headers,
)
if http_profile == "forced_content_length":
resp.content_length = get_chunksize(
output_format, queue_item.streamdetails.duration or 120
)
resp.content_type = f"audio/{output_format.output_format_str}"
http_profile: str = await self.mass.config.get_player_config_value(
queue_id, CONF_HTTP_PROFILE
)
if http_profile == "forced_content_length" and queue_item.duration:
# guess content length based on duration
resp.content_length = get_chunksize(output_format, queue_item.duration)
elif http_profile == "chunked":
resp.enable_chunked_encoding()

Expand Down Expand Up @@ -434,18 +432,12 @@ async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
enable_icy = request.headers.get("Icy-MetaData", "") == "1" and icy_preference != "disabled"
icy_meta_interval = 256000 if icy_preference == "full" else 16384

# prepare request, add some DLNA/UPNP compatible headers
http_profile: str = await self.mass.config.get_player_config_value(
queue_id, CONF_HTTP_PROFILE
)
# prepare request, add some DLNA/UPNP compatible headers
headers = {
**DEFAULT_STREAM_HEADERS,
**ICY_HEADERS,
"Content-Type": f"audio/{output_format.output_format_str}",
"Accept-Ranges": "none",
"Cache-Control": "no-cache",
"Connection": "close",
"Content-Type": f"audio/{output_format.output_format_str}",
}
if enable_icy:
headers["icy-metaint"] = str(icy_meta_interval)
Expand All @@ -455,10 +447,15 @@ async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
reason="OK",
headers=headers,
)
http_profile: str = await self.mass.config.get_player_config_value(
queue_id, CONF_HTTP_PROFILE
)
if http_profile == "forced_content_length":
resp.content_length = get_chunksize(output_format, 24 * 2600)
# just set an insane high content length to make sure the player keeps playing
resp.content_length = get_chunksize(output_format, 12 * 3600)
elif http_profile == "chunked":
resp.enable_chunked_encoding()

await resp.prepare(request)

# return early if this is not a GET request
Expand Down Expand Up @@ -534,16 +531,35 @@ async def serve_announcement_stream(self, request: web.Request) -> web.Response:
# work out output format/details
fmt = request.match_info.get("fmt", announcement_url.rsplit(".")[-1])
audio_format = AudioFormat(content_type=ContentType.try_parse(fmt))
# prepare request, add some DLNA/UPNP compatible headers
headers = {
**DEFAULT_STREAM_HEADERS,
"Content-Type": f"audio/{audio_format.output_format_str}",
}

http_profile: str = await self.mass.config.get_player_config_value(
player_id, CONF_HTTP_PROFILE
)
if http_profile == "forced_content_length":
# given the fact that an announcement is just a short audio clip,
# just send it over completely at once so we have a fixed content length
data = b""
async for chunk in self.get_announcement_stream(
announcement_url=announcement_url,
output_format=audio_format,
use_pre_announce=use_pre_announce,
):
data += chunk
return web.Response(
body=data,
content_type=f"audio/{audio_format.output_format_str}",
headers=DEFAULT_STREAM_HEADERS,
)

resp = web.StreamResponse(
status=200,
reason="OK",
headers=headers,
headers=DEFAULT_STREAM_HEADERS,
)
resp.content_type = f"audio/{audio_format.output_format_str}"
if http_profile == "chunked":
resp.enable_chunked_encoding()

await resp.prepare(request)

# return early if this is not a GET request
Expand Down Expand Up @@ -835,7 +851,7 @@ async def get_media_stream(
)
elif streamdetails.stream_type == StreamType.ICY:
audio_source = get_icy_radio_stream(self.mass, streamdetails.path, streamdetails)
elif streamdetails.stream_type in (StreamType.HLS, StreamType.ENCRYPTED_HLS):
elif streamdetails.stream_type == StreamType.HLS:
substream = await get_hls_substream(self.mass, streamdetails.path)
audio_source = substream.path
if streamdetails.media_type == MediaType.RADIO:
Expand Down
14 changes: 9 additions & 5 deletions music_assistant/server/helpers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,8 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, Str
# unfold first url of playlist
return await resolve_radio_stream(mass, line.path)
raise InvalidDataError("No content found in playlist")
except IsHLSPlaylist as err:
stream_type = StreamType.ENCRYPTED_HLS if err.encrypted else StreamType.HLS
except IsHLSPlaylist:
stream_type = StreamType.HLS

except Exception as err:
LOGGER.warning("Error while parsing radio URL %s: %s", url, err)
Expand Down Expand Up @@ -800,14 +800,18 @@ def get_chunksize(
fmt: AudioFormat,
seconds: int = 1,
) -> int:
"""Get a default chunksize for given contenttype."""
pcm_size = int(fmt.sample_rate * (fmt.bit_depth / 8) * 2 * seconds)
"""Get a default chunk/file size for given contenttype in bytes."""
pcm_size = int(fmt.sample_rate * (fmt.bit_depth / 8) * fmt.channels * seconds)
if fmt.content_type.is_pcm() or fmt.content_type == ContentType.WAV:
return pcm_size
if fmt.content_type in (ContentType.WAV, ContentType.AIFF, ContentType.DSF):
return pcm_size
if fmt.bit_rate:
return int(((fmt.bit_rate * 1000) / 8) * seconds)
if fmt.content_type in (ContentType.FLAC, ContentType.WAVPACK, ContentType.ALAC):
return int(pcm_size * 0.8)
# assume 74.7% compression ratio (level 0)
# source: https://z-issue.com/wp/flac-compression-level-comparison/
return int(pcm_size * 0.747)
if fmt.content_type in (ContentType.MP3, ContentType.OGG):
return int((320000 / 8) * seconds)
if fmt.content_type in (ContentType.AAC, ContentType.M4A):
Expand Down
3 changes: 2 additions & 1 deletion music_assistant/server/helpers/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ def get_ffmpeg_args(
str(output_format.channels),
]
if output_format.output_format_str == "flac":
output_args += ["-compression_level", "6"]
# use level 0 compression for fastest encoding
output_args += ["-compression_level", "0"]
output_args += [output_path]

# edge case: source file is not stereo - downmix to stereo
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/server/helpers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _create_image():
except UnidentifiedImageError:
raise FileNotFoundError(f"Invalid image: {path_or_url}")
if size:
img.thumbnail((size, size), Image.LANCZOS)
img.thumbnail((size, size), Image.Resampling.LANCZOS)

mode = "RGBA" if image_format == "PNG" else "RGB"
img.convert(mode).save(data, image_format, optimize=True)
Expand Down
6 changes: 1 addition & 5 deletions music_assistant/server/helpers/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@
class IsHLSPlaylist(InvalidDataError):
"""The playlist from an HLS stream and should not be parsed."""

encrypted: bool = False


@dataclass
class PlaylistItem:
Expand Down Expand Up @@ -167,9 +165,7 @@ async def fetch_playlist(
raise InvalidDataError(msg) from err

if raise_on_hls and "#EXT-X-VERSION:" in playlist_data or "#EXT-X-STREAM-INF:" in playlist_data:
exc = IsHLSPlaylist()
exc.encrypted = "#EXT-X-KEY:" in playlist_data
raise exc
raise IsHLSPlaylist

if url.endswith((".m3u", ".m3u8")):
playlist = parse_m3u(playlist_data)
Expand Down
Loading

0 comments on commit 4479cd7

Please sign in to comment.