Skip to content

Conversation

@benklop
Copy link
Contributor

@benklop benklop commented Jan 19, 2026

Enhance the Digitally Incorporated provider to handle 403 (and other) errors by attempting the next URL in the list. Refactor stream URL handling to support multiple URL types and improve error logging. Include tests for JSON serialization of stream details.

This change requires an update to music-assistant/server, music-assistant/models#155

Supercedes #2984

nicholas-gh and others added 3 commits January 16, 2026 18:01
…e giving up

I'm finding that prem2 (often the first URL in in the list) returns 403 'Too many clients'. Failing over to the next URL works and plays the stream.
Copilot AI review requested due to automatic review settings January 19, 2026 22:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds mirrored stream URL support for the Digitally Incorporated provider so playback can fail over to the next URL on errors, and updates serialization/helpers to handle the new stream path types.

Changes:

  • Digitally Incorporated provider now returns multiple stream URLs as mirrors and uses HTTP stream type.
  • Audio streaming helper now supports mirrored URL lists via a new get_mirror_stream path.
  • JSON serialization helper enhanced to better handle dataclasses/containers; adds a small serialization test.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
tests/test_json_serialization.py Adds a regression test ensuring lists of MultiPartPath can be JSON serialized.
music_assistant/providers/digitally_incorporated/__init__.py Refactors stream URL retrieval to return multiple StreamMirror entries and switches to HTTP stream type.
music_assistant/helpers/json.py Extends get_serializable_value to handle dataclasses and adds defensive to_json logging.
music_assistant/helpers/audio.py Adds mirror-stream handling and introduces get_mirror_stream for URL failover.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1269 to +1277
except Exception as err:
LOGGER.warning(
"Error streaming from mirror %s (priority=%s) for %s: %s",
mirror.path,
mirror.priority,
streamdetails.uri,
err,
exc_info=err,
)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_mirror_stream catches all Exception, which includes asyncio.CancelledError in supported Python versions. This can swallow task cancellation and continue trying other mirrors, and may log warnings/errors during shutdown. Re-raise asyncio.CancelledError (and GeneratorExit) immediately before logging/trying the next mirror, similar to the pattern used in get_media_stream.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +1259 to +1266
try:
async for chunk in get_http_stream(
mass,
mirror.path,
streamdetails,
# There's no verify_ssl on StreamMirror; default to True.
verify_ssl=True,
):
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_http_stream always performs a HEAD request when streamdetails.size is unset, so mirror failover will do an extra HEAD per mirror even for non-seekable radio streams. For live endpoints that don’t support HEAD, this can cause avoidable failures. Consider adding a way to bypass the HEAD probe when seek_position==0 and seeking is disabled, or add a flag in get_http_stream/get_mirror_stream to skip the probe for mirrors.

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 47
isinstance(obj, list | set | filter | tuple | dict_values | dict_keys | dict_values)
or obj.__class__ == "dict_valueiterator"
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is ineffective: obj.__class__ is a type, so comparing it to the string "dict_valueiterator" will always be False. If you need to special-case dict value iterators, compare type(obj).__name__ (or use an Iterator/Iterable check) instead. Also, dict_values is listed twice in the isinstance union.

Suggested change
isinstance(obj, list | set | filter | tuple | dict_values | dict_keys | dict_values)
or obj.__class__ == "dict_valueiterator"
isinstance(obj, list | set | filter | tuple | dict_values | dict_keys)
or type(obj).__name__ == "dict_valueiterator"

Copilot uses AI. Check for mistakes.
return stream_url
# Log all available URLs
for i, url in enumerate(stream_list):
self.logger.debug("%s: Available stream URL %d: %s", self.domain, i + 1, url)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stream_list contains StreamMirror objects, but the debug log prints the object (%s, url) rather than the actual URL string. This makes logs harder to read and can omit the URL if StreamMirror.__str__ isn’t implemented. Log url.path (and optionally url.priority) instead.

Suggested change
self.logger.debug("%s: Available stream URL %d: %s", self.domain, i + 1, url)
self.logger.debug(
"%s: Available stream URL %d: %s (priority: %s)",
self.domain,
i + 1,
url.path,
url.priority,
)

Copilot uses AI. Check for mistakes.
@use_cache(CACHE_STREAM_URLS)
async def _get_stream_urls(self, network_key: str, channel_key: str) -> list[StreamMirror]:
"""Get the streaming URLs for a channel."""
self.logger.debug("%s: Getting stream URL for %s:%s", self.domain, network_key, channel_key)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This debug line still says "Getting stream URL" even though the method now returns multiple URLs. Consider updating the message to "stream URLs" to match the new behavior and avoid confusion when troubleshooting.

Copilot uses AI. Check for mistakes.
Comment on lines 336 to 338
# Get the stream URL
stream_url = await self._get_stream_url(network_key, channel_key)
stream_url = await self._get_stream_urls(network_key, channel_key)

Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stream_url now holds a list of StreamMirror entries (multiple URLs). Renaming to something like stream_urls/mirrors would make the type/intent clearer and avoid confusion for future readers.

Copilot uses AI. Check for mistakes.
Comment on lines 555 to +566
if isinstance(streamdetails.path, list):
# multi part stream
audio_source = get_multi_file_stream(mass, streamdetails, seek_position)
seek_position = 0 # handled by get_multi_file_stream
logger.debug(
"media_stream path list len=%s first_type=%s",
len(streamdetails.path),
type(streamdetails.path[0]).__name__ if streamdetails.path else None,
)
if all(isinstance(part, MultiPartPath) for part in streamdetails.path):
# multi part stream
audio_source = get_multi_file_stream(mass, streamdetails, seek_position)
seek_position = 0 # handled by get_multi_file_stream
elif all(isinstance(part, StreamMirror) for part in streamdetails.path):
# mirror URLs
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all(...) returns True for an empty list, so an empty streamdetails.path will be treated as a MultiPartPath multipart stream and routed into get_multi_file_stream, which is incorrect and will later fail with confusing errors. Add an explicit empty-list check (raise InvalidDataError) or gate the all(...) checks with streamdetails.path and ... so empty lists are rejected deterministically.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants