Skip to content
Draft
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
3 changes: 2 additions & 1 deletion music_assistant/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ async def start_mass() -> None:
loop.set_exception_handler(_global_loop_exception_handler)
try:
await mass.start()
except Exception:
except Exception as e:
logger.exception("Failed to start MusicAssistant: %s", e)
# exit immediately if startup fails
loop.stop()
raise
Expand Down
13 changes: 7 additions & 6 deletions music_assistant/helpers/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,12 +455,13 @@ async def check_ffmpeg_version() -> None:
# use globals as in-memory cache
await set_global_cache_values({CACHE_ATTR_LIBSOXR_PRESENT: libsoxr_support})

major_version = int("".join(char for char in version.split(".")[0] if not char.isalpha()))
if major_version < MINIMAL_FFMPEG_VERSION:
raise AudioError(
f"FFmpeg version {version} is not supported. "
f"Minimal version required is {MINIMAL_FFMPEG_VERSION}."
)
if version != "N": # N-versions are development versions, so we assume they are recent enough
major_version = int("".join(char for char in version.split(".")[0] if not char.isalpha()))
if major_version < MINIMAL_FFMPEG_VERSION:
raise AudioError(
f"FFmpeg version {version} is not supported. "
f"Minimal version required is {MINIMAL_FFMPEG_VERSION}."
)

LOGGER.info(
"Detected ffmpeg version %s %s",
Expand Down
236 changes: 180 additions & 56 deletions music_assistant/providers/soundcloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import time
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import parse_qs, urlparse

Expand All @@ -16,7 +17,12 @@
ProviderFeature,
StreamType,
)
from music_assistant_models.errors import InvalidDataError, LoginFailed, MediaNotFoundError
from music_assistant_models.errors import (
InvalidDataError,
LoginFailed,
MediaNotFoundError,
UnplayableMediaError,
)
from music_assistant_models.media_items import (
Artist,
AudioFormat,
Expand All @@ -37,6 +43,8 @@

CONF_CLIENT_ID = "client_id"
CONF_AUTHORIZATION = "authorization"
CONF_AAC_SUPPORT = "aac_support"
CONF_HLS_PREFERENCE = "hls_preference"

SUPPORTED_FEATURES = {
ProviderFeature.LIBRARY_ARTISTS,
Expand Down Expand Up @@ -97,6 +105,30 @@ async def get_config_entries(
label="Authorization",
required=True,
),
ConfigEntry(
key=CONF_AAC_SUPPORT,
type=ConfigEntryType.BOOLEAN,
label="(Experimental) HQ AAC Support",
description=(
"Experimental support to pick 256k AAC streams for Go+ subscriptions,"
"currently slow to start streaming (e.g. 10 seconds)"
),
advanced=True,
required=True,
default_value=False,
),
ConfigEntry(
key=CONF_HLS_PREFERENCE,
type=ConfigEntryType.BOOLEAN,
label="(Experimental) HLS Preference",
description=(
"Prefer HLS streams over progressive streams (if available), "
"shorter startup time but seeking may be less reliable."
),
advanced=True,
required=True,
default_value=False,
),
)


Expand Down Expand Up @@ -408,70 +440,153 @@ async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[

return tracks

async def _get_stream_url(self, item_id: str) -> str | None:
"""Get stream URL, preferring progressive (HTTP) over HLS.
# Handle the following known presets:
# hq: aac_256k, abr_hq, aac_1_0
# sq: aac_160k, abr_sq, mp3_1_0, opus_0_0
def _get_bitrate(self, preset: str) -> int | None:
"""Extract bitrate from SoundCloud transcoding preset string."""
if "256k" in preset or "abr_hq" in preset or "aac_1_0" in preset:
return 256
if "160k" in preset:
return 160
if "abr_sq" in preset or "mp3_1_0" in preset:
return 128
if "opus" in preset:
return 64
return None

# Return all the valid audio formats found in preference order,
# along with their stream type and bitrate if available
def _get_preferred_formats(
self, track_info: dict[str, Any]
) -> Generator[tuple[dict[str, Any], StreamType, AudioFormat], None]:
"""Get preferred formats, preferring progressive (HTTP) over HLS.

SoundCloud HLS playlists can have limited content windows (~10 min) which
cause seeking failures mid-track. Progressive HTTP URLs support full
range-based seeking across the entire track duration.
"""
transcodings = track_info.get("media", {}).get("transcodings", [])

# Prefer higher quality music if enabled
# Ignore formats abr_hq and abr_sq
# which do not seem to get URLs when requesting the stream URL,
# even though they are listed in the track details response
valid_formats = (
("aac_256k", "aac_1_0", "mp3", "opus")
if self.config.get_value(CONF_AAC_SUPPORT)
else ("mp3", "opus")
)

valid_protocols = (
("hls", "progressive", None)
if self.config.get_value(CONF_HLS_PREFERENCE)
else ("progressive", "hls", None)
)

# Iterate over all combinations of valid protocols and formats
# to yield the best available options first
# TODO: make more efficient by sorting by a priority score
for preferred_protocol in valid_protocols:
for preferred_format in valid_formats:
for transcoding in transcodings:
preset = transcoding.get("preset", "")
protocol = transcoding.get("format", {}).get("protocol", "")
if not preset.startswith(preferred_format):
continue
if preferred_protocol is not None and protocol != preferred_protocol:
continue

mime_type = transcoding.get("format", {}).get("mime_type", "")
content_type = (
ContentType.MP3
if mime_type.startswith("audio/mpeg")
else ContentType.AAC
if mime_type.startswith("audio/mp4")
else ContentType.UNKNOWN
)
stream_type = (
StreamType.HTTP
if protocol == "progressive"
else StreamType.HLS
if protocol == "hls"
else StreamType.UNKNOWN
)

yield (
transcoding,
stream_type,
AudioFormat(
content_type=ContentType.M4A
if content_type == ContentType.AAC
else ContentType.MP3
if content_type == ContentType.MP3
else ContentType.OGG,
codec_type=content_type,
sample_rate=48000 if content_type == ContentType.AAC else 44100,
bit_rate=self._get_bitrate(preset),
channels=2,
),
)

async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
"""Return the content details for the given track when it will be streamed."""
full_json = await self._soundcloud.get_track_details(item_id)
if not (full_json and isinstance(full_json, list)):
return None
raise UnplayableMediaError("No track details available from SoundCloud.")
track_info = full_json[0]
track_auth = track_info.get("track_authorization")
if not track_auth:
return None
transcodings = track_info.get("media", {}).get("transcodings", [])
# Two passes: prefer progressive mp3, fall back to any mp3 (which may be HLS)
for preferred_protocol in ("progressive", None):
for transcoding in transcodings:
preset = transcoding.get("preset", "")
protocol = transcoding.get("format", {}).get("protocol", "")
if not preset.startswith("mp3"):
continue
if preferred_protocol is not None and protocol != preferred_protocol:
continue
stream_url = (
f"{transcoding['url']}?client_id={self._soundcloud.client_id}"
f"&track_authorization={track_auth}"
raise UnplayableMediaError("No authentication provided for the track from SoundCloud.")

for transcoding, stream_type, audio_format in self._get_preferred_formats(track_info):
stream_url = (
f"{transcoding['url']}?client_id={self._soundcloud.client_id}"
f"&track_authorization={track_auth}"
)
duration_ms = transcoding.get("duration", None)

req = await self._soundcloud.get(stream_url, headers=self._soundcloud.headers)
if isinstance(req, dict) and "url" in req:
url = str(req["url"])

# Parse CDN URL expiry to avoid seeking with an expired URL.
# SoundCloud CDN URLs are short-lived; seeking starts a new FFmpeg process
# that makes a fresh HTTP request to the stored URL, which may have expired.
expiration = 30 # conservative default if expiry cannot be determined
if parsed_qs := parse_qs(urlparse(url).query):
for param in ("Expires", "expire"):
if expire_ts := parsed_qs.get(param, [None])[0]:
expiration = max(30, int(expire_ts) - int(time.time()) - 10)
break

self.logger.debug(
"Selected SoundCloud stream for track %s: protocol=%s, codec=%s, "
"bitrate=%skbps, expires in %s seconds",
item_id,
stream_type.name,
audio_format.codec_type.name,
audio_format.bit_rate,
expiration,
)
return StreamDetails(
provider=self.instance_id,
item_id=item_id,
# return the actual stream details so that the user
# can see what quality is being returned
audio_format=audio_format,
extra_input_args=["-seekable", "1"] if stream_type == StreamType.HTTP else [],
stream_type=stream_type,
path=url,
can_seek=True,
allow_seek=True,
# must provide duration otherwise won't seek
duration=int(duration_ms / 1000) if duration_ms is not None else None,
expiration=expiration,
)
req = await self._soundcloud.get(stream_url, headers=self._soundcloud.headers)
if isinstance(req, dict) and "url" in req:
return str(req["url"])
return None

async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
"""Return the content details for the given track when it will be streamed."""
url = await self._get_stream_url(item_id)
if not url:
msg = f"No stream URL available for Soundcloud track {item_id}"
raise MediaNotFoundError(msg)
# Parse CDN URL expiry to avoid seeking with an expired URL.
# SoundCloud CDN URLs are short-lived; seeking starts a new FFmpeg process
# that makes a fresh HTTP request to the stored URL, which may have expired.
expiration = 30 # conservative default if expiry cannot be determined
if parsed_qs := parse_qs(urlparse(url).query):
for param in ("Expires", "expire"):
if expire_ts := parsed_qs.get(param, [None])[0]:
expiration = max(30, int(expire_ts) - int(time.time()) - 10)
break
return StreamDetails(
provider=self.instance_id,
item_id=item_id,
# let ffmpeg work out the details itself as
# soundcloud uses a mix of different content types and streaming methods
audio_format=AudioFormat(
content_type=ContentType.UNKNOWN,
),
stream_type=StreamType.HLS
if url.startswith("https://cf-hls-media.sndcdn.com")
else StreamType.HTTP,
path=url,
can_seek=True,
allow_seek=True,
expiration=expiration,
)
msg = f"No stream URL available for Soundcloud track {item_id}"
raise MediaNotFoundError(msg)

async def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist:
"""Parse a Soundcloud user response to Artist model object."""
Expand Down Expand Up @@ -554,6 +669,17 @@ async def _parse_track(self, track_obj: dict[str, Any], playlist_position: int =
"""Parse a Soundcloud Track response to a Track model object."""
name, version = parse_title_and_version(track_obj["title"])
track_id = str(track_obj["id"])

# Select the AudioFormat from the best available transcoding format for this track
_, _, audio_format = next(
self._get_preferred_formats(track_obj),
(
None,
None,
AudioFormat(content_type=ContentType.MP3),
), # Default to MP3 if no format info available
)

track = Track(
item_id=track_id,
provider=self.domain,
Expand All @@ -565,9 +691,7 @@ async def _parse_track(self, track_obj: dict[str, Any], playlist_position: int =
item_id=track_id,
provider_domain=self.domain,
provider_instance=self.instance_id,
audio_format=AudioFormat(
content_type=ContentType.MP3,
),
audio_format=audio_format,
url=track_obj["permalink_url"],
)
},
Expand Down
Loading