-
-
Notifications
You must be signed in to change notification settings - Fork 335
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?
Changes from 3 commits
dd98b50
ff579a1
8a5f2f1
8dd2459
cf04a0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,31 +2,63 @@ | |||||||||
|
|
||||||||||
| import asyncio | ||||||||||
| import base64 | ||||||||||
| import logging | ||||||||||
| from _collections_abc import dict_keys, dict_values | ||||||||||
| from dataclasses import asdict, is_dataclass | ||||||||||
| from types import MethodType | ||||||||||
| from typing import Any, TypeVar | ||||||||||
|
|
||||||||||
| import aiofiles | ||||||||||
| import orjson | ||||||||||
| from mashumaro.mixins.orjson import DataClassORJSONMixin | ||||||||||
|
|
||||||||||
| LOGGER = logging.getLogger(__name__) | ||||||||||
|
|
||||||||||
| JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) | ||||||||||
| JSON_DECODE_EXCEPTIONS = (orjson.JSONDecodeError,) | ||||||||||
|
|
||||||||||
| DO_NOT_SERIALIZE_TYPES = (MethodType, asyncio.Task) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def get_serializable_value(obj: Any, raise_unhandled: bool = False) -> Any: | ||||||||||
| """Parse the value to its serializable equivalent.""" | ||||||||||
| """Parse the value to its serializable equivalent. | ||||||||||
|
|
||||||||||
| This function will convert dataclasses, dicts and iterable containers into | ||||||||||
| JSON-serializable primitives. It intentionally returns primitives (lists, | ||||||||||
| dicts, strings, numbers) for any complex object that cannot be serialized | ||||||||||
| directly by orjson to avoid infinite recursion in the default serializer. | ||||||||||
| """ | ||||||||||
| if getattr(obj, "do_not_serialize", None): | ||||||||||
| return None | ||||||||||
|
|
||||||||||
| # Convert dataclass *instances* to dicts so nested dataclasses get handled recursively. | ||||||||||
| # `is_dataclass` can also return True for dataclass *types*, so ensure we only | ||||||||||
| # pass instances to `asdict` to avoid type errors (mypy). | ||||||||||
| if is_dataclass(obj) and not isinstance(obj, type): | ||||||||||
| return get_serializable_value(asdict(obj)) | ||||||||||
|
|
||||||||||
| # Handle plain dicts | ||||||||||
| if isinstance(obj, dict): | ||||||||||
| return {k: get_serializable_value(v) for k, v in obj.items()} | ||||||||||
|
|
||||||||||
| # Handle iterable containers | ||||||||||
| if ( | ||||||||||
| isinstance(obj, list | set | filter | tuple | dict_values | dict_keys | dict_values) | ||||||||||
| or obj.__class__ == "dict_valueiterator" | ||||||||||
|
Comment on lines
46
to
47
|
||||||||||
| 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" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,7 +38,7 @@ | |||||||||||||||||
| SearchResults, | ||||||||||||||||||
| UniqueList, | ||||||||||||||||||
| ) | ||||||||||||||||||
| from music_assistant_models.streamdetails import StreamDetails | ||||||||||||||||||
| from music_assistant_models.streamdetails import StreamDetails, StreamMirror | ||||||||||||||||||
|
|
||||||||||||||||||
| from music_assistant.controllers.cache import use_cache | ||||||||||||||||||
| from music_assistant.helpers.throttle_retry import Throttler | ||||||||||||||||||
|
|
@@ -72,7 +72,7 @@ | |||||||||||||||||
| API_TIMEOUT = 30 | ||||||||||||||||||
| CACHE_CHANNELS = 86400 # 24 hours | ||||||||||||||||||
| CACHE_GENRES = 86400 # 24 hours | ||||||||||||||||||
| CACHE_STREAM_URL = 3600 # 1 hour | ||||||||||||||||||
| CACHE_STREAM_URLS = 3600 # 1 hour | ||||||||||||||||||
|
|
||||||||||||||||||
| # Rate limiting | ||||||||||||||||||
| RATE_LIMIT = 2 # requests per period | ||||||||||||||||||
|
|
@@ -334,7 +334,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea | |||||||||||||||||
| network_key, channel_key = self._validate_item_id(item_id) | ||||||||||||||||||
|
|
||||||||||||||||||
| # 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) | ||||||||||||||||||
|
|
||||||||||||||||||
|
Comment on lines
336
to
338
|
||||||||||||||||||
| return StreamDetails( | ||||||||||||||||||
| provider=self.instance_id, | ||||||||||||||||||
|
|
@@ -343,7 +343,8 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea | |||||||||||||||||
| content_type=ContentType.UNKNOWN, # Let ffmpeg auto-detect | ||||||||||||||||||
| ), | ||||||||||||||||||
| media_type=MediaType.RADIO, | ||||||||||||||||||
| stream_type=StreamType.ICY, | ||||||||||||||||||
| # Use HTTP stream type with mirrors so we can try multiple URLs | ||||||||||||||||||
| stream_type=StreamType.HTTP, | ||||||||||||||||||
| path=stream_url, | ||||||||||||||||||
|
Comment on lines
345
to
348
|
||||||||||||||||||
| allow_seek=False, | ||||||||||||||||||
| can_seek=False, | ||||||||||||||||||
|
|
@@ -606,9 +607,9 @@ async def _get_genre_mapping(self, network_key: str) -> dict[int, str]: | |||||||||||||||||
| ) | ||||||||||||||||||
| return {} | ||||||||||||||||||
|
|
||||||||||||||||||
| @use_cache(CACHE_STREAM_URL) | ||||||||||||||||||
| async def _get_stream_url(self, network_key: str, channel_key: str) -> str: | ||||||||||||||||||
| """Get the streaming URL for a channel.""" | ||||||||||||||||||
| @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) | ||||||||||||||||||
|
||||||||||||||||||
|
|
||||||||||||||||||
| listen_key = self.config.get_value("listen_key") | ||||||||||||||||||
|
|
@@ -622,28 +623,29 @@ async def _get_stream_url(self, network_key: str, channel_key: str) -> str: | |||||||||||||||||
| network_key, f"listen/premium_high/{channel_key}", use_https=True, **params | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Use the first stream URL from the playlist | ||||||||||||||||||
| self.logger.debug( | ||||||||||||||||||
| "%s: Digitally Incorporated playlist returned %d URLs", self.domain, len(playlist) | ||||||||||||||||||
| ) | ||||||||||||||||||
| if not playlist or not isinstance(playlist, list): | ||||||||||||||||||
| msg = f"{self.domain}: No stream URLs returned from Digitally Incorporated API" | ||||||||||||||||||
| raise MediaNotFoundError(msg) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Log all available URLs for debugging | ||||||||||||||||||
| for i, url in enumerate(playlist): | ||||||||||||||||||
| self.logger.debug("%s: Available stream URL %d: %s", self.domain, i + 1, url) | ||||||||||||||||||
| # Represent returned URLs as StreamMirror objects (explicit mirror semantics) | ||||||||||||||||||
| stream_list = [StreamMirror(url) for url in playlist if url and isinstance(url, str)] | ||||||||||||||||||
|
|
||||||||||||||||||
| # Use the first URL - Digitally Incorporated typically returns them in priority order | ||||||||||||||||||
| stream_url: str = str(playlist[0]) | ||||||||||||||||||
| self.logger.debug("%s: Selected stream URL: %s", self.domain, stream_url) | ||||||||||||||||||
| self.logger.debug( | ||||||||||||||||||
| "%s: Filtered %d valid stream URLs from playlist of %d URLs", | ||||||||||||||||||
| self.domain, | ||||||||||||||||||
| len(stream_list), | ||||||||||||||||||
| len(playlist), | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Validate the stream URL | ||||||||||||||||||
| if not stream_url or not isinstance(stream_url, str): | ||||||||||||||||||
| msg = f"{self.domain}: Invalid stream URL received: {stream_url}" | ||||||||||||||||||
| if not stream_list: | ||||||||||||||||||
| msg = f"{self.domain}: No valid stream URLs found in the playlist" | ||||||||||||||||||
| raise MediaNotFoundError(msg) | ||||||||||||||||||
|
|
||||||||||||||||||
| 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) | ||||||||||||||||||
|
||||||||||||||||||
| 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, | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| """Tests for JSON serialization helpers.""" | ||
|
|
||
| from music_assistant_models.streamdetails import MultiPartPath | ||
|
|
||
| from music_assistant.helpers.json import json_dumps | ||
|
|
||
|
|
||
| def test_multipartpath_list_serializes() -> None: | ||
| """Ensure a list of MultiPartPath serializes to JSON.""" | ||
| data = [MultiPartPath("http://a"), MultiPartPath("b", 12.3)] | ||
| json_str = json_dumps(data) | ||
| assert "http://a" in json_str | ||
| assert "12.3" in json_str |
Uh oh!
There was an error while loading. Please reload this page.