-
-
Notifications
You must be signed in to change notification settings - Fork 271
Improve stream URL handling with failover support #2996
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
base: dev
Are you sure you want to change the base?
Improve stream URL handling with failover support #2996
Conversation
…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.
There was a problem hiding this 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_streampath. - 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.
| 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, | ||
| ) |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| 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, | ||
| ): |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| isinstance(obj, list | set | filter | tuple | dict_values | dict_keys | dict_values) | ||
| or obj.__class__ == "dict_valueiterator" |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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" |
| 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) |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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, | |
| ) |
| @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) |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| # 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) | ||
|
|
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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 |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
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