-
-
Notifications
You must be signed in to change notification settings - Fork 384
Di fm favorites #3458
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?
Di fm favorites #3458
Changes from 7 commits
d4c1911
155b2d5
d9903f4
623f91e
13d7f8c
bc2d0b2
c94c2e3
aea77d8
527dc89
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,13 +9,17 @@ | |||||
| - ClassicalRadio | ||||||
| - ZenRadio | ||||||
|
|
||||||
| The provider requires a premium account and listen key for authentication. | ||||||
| The provider requires a premium account and listen key for streaming. Library | ||||||
| favorites are read from each network's listen host ``favorites.pls`` (read-only; | ||||||
| edit favorites on the service website). | ||||||
| """ | ||||||
|
|
||||||
| from __future__ import annotations | ||||||
|
|
||||||
| import re | ||||||
| from collections.abc import AsyncGenerator | ||||||
| from typing import TYPE_CHECKING, Any | ||||||
| from urllib.parse import urlparse | ||||||
|
|
||||||
| import aiohttp | ||||||
| from music_assistant_models.enums import ( | ||||||
|
|
@@ -73,6 +77,26 @@ | |||||
| CACHE_CHANNELS = 86400 # 24 hours | ||||||
| CACHE_GENRES = 86400 # 24 hours | ||||||
| CACHE_STREAM_URL = 3600 # 1 hour | ||||||
| CACHE_FAVORITES = 300 # 5 minutes | ||||||
|
|
||||||
| # Favorites playlist on listen.* (premium tier; ``public3`` is legacy and not used here). | ||||||
| _FILE_LINE_RE = re.compile(r"^\s*File\d+\s*=\s*(.+?)\s*$", re.IGNORECASE) | ||||||
| _PLS_CHANNEL_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.IGNORECASE) | ||||||
|
|
||||||
| # Root ``/favorites.pls`` (no tier) often returns HTTP 406 — use premium only. | ||||||
| FAVORITES_PLS_PATHS: tuple[str, ...] = ("premium/favorites.pls",) | ||||||
| FAVORITES_PLS_EXTRA_PARAMS: dict[str, str] = {"download": "1"} | ||||||
|
|
||||||
| # Premium PLS may use codec-specific mounts (``pianojazz_aac``) while API ``key`` is ``pianojazz``. | ||||||
| PLS_KEY_CODEC_SUFFIXES: tuple[str, ...] = ("_aac", "_mp3") | ||||||
|
|
||||||
| # Optional short listen-host basename prefixes (besides ``3rdparty_{network}_`` / ``{network}_``). | ||||||
| NETWORK_PLS_SHORT_PREFIXES: dict[str, tuple[str, ...]] = { | ||||||
| "radiotunes": ("rt_",), | ||||||
| "rockradio": ("rr_",), | ||||||
| "classicalradio": ("cr_",), | ||||||
| "zenradio": ("zr_",), | ||||||
| } | ||||||
|
|
||||||
| # Rate limiting | ||||||
| RATE_LIMIT = 2 # requests per period | ||||||
|
|
@@ -139,7 +163,10 @@ async def get_config_entries( | |||||
| key="listen_key", | ||||||
| type=ConfigEntryType.STRING, | ||||||
| label="Listen Key", | ||||||
| description="Your premium listen key. Get this from your account settings.", | ||||||
| description=( | ||||||
| "Premium listen key from account settings. Used for playback and to load " | ||||||
| "your favorites (from favorites.pls); favorites cannot be edited here." | ||||||
| ), | ||||||
| required=True, | ||||||
| ) | ||||||
| ) | ||||||
|
|
@@ -272,13 +299,22 @@ async def search( | |||||
| return results | ||||||
|
|
||||||
| async def get_library_radios(self) -> AsyncGenerator[Radio, None]: | ||||||
| """Retrieve all radio stations from active networks.""" | ||||||
| """Retrieve user's favorite radio stations from active networks.""" | ||||||
| for network_key in self._get_active_networks(): | ||||||
| try: | ||||||
| channel_keys = await self._get_favorite_channel_keys(network_key) | ||||||
| if not channel_keys: | ||||||
| continue | ||||||
|
|
||||||
| channels = await self._get_channels(network_key) | ||||||
| channels_by_key = { | ||||||
| str(ch["key"]): ch for ch in channels if isinstance(ch, dict) and ch.get("key") | ||||||
| } | ||||||
|
|
||||||
| for channel_data in channels: | ||||||
| yield self._channel_to_radio(channel_data, network_key) | ||||||
| for channel_key in channel_keys: | ||||||
| ch_data = self._channel_data_for_pls_key(channel_key, channels_by_key) | ||||||
| if ch_data: | ||||||
| yield self._channel_to_radio(ch_data, network_key) | ||||||
|
|
||||||
| except ( | ||||||
| ProviderUnavailableError, | ||||||
|
|
@@ -288,7 +324,10 @@ async def get_library_radios(self) -> AsyncGenerator[Radio, None]: | |||||
| KeyError, | ||||||
| ) as err: | ||||||
| self.logger.debug( | ||||||
| "%s: Failed to get channels for network %s: %s", self.domain, network_key, err | ||||||
| "%s: Failed to get favorites for network %s: %s", | ||||||
| self.domain, | ||||||
| network_key, | ||||||
| err, | ||||||
| ) | ||||||
| continue | ||||||
|
|
||||||
|
|
@@ -458,7 +497,9 @@ async def _api_request( | |||||
| self, | ||||||
| network_key: str, | ||||||
| endpoint: str, | ||||||
| method: str = "GET", | ||||||
| use_https: bool = True, | ||||||
| json_body: dict[str, Any] | None = None, | ||||||
| **params: Any, | ||||||
| ) -> Any: | ||||||
| """Make a generic API request to Digitally Incorporated.""" | ||||||
|
|
@@ -470,7 +511,9 @@ async def _api_request( | |||||
|
|
||||||
| async with ( | ||||||
| self._throttler, | ||||||
| self.mass.http_session.get(url, params=params, timeout=timeout) as resp, | ||||||
| self.mass.http_session.request( | ||||||
| method, url, params=params, json=json_body, timeout=timeout | ||||||
| ) as resp, | ||||||
| ): | ||||||
| if resp.status == 403: | ||||||
| msg = f"{self.domain}: Access denied - check your listen key and subscription" | ||||||
|
|
@@ -483,8 +526,165 @@ async def _api_request( | |||||
| raise ProviderUnavailableError(msg) | ||||||
|
|
||||||
| resp.raise_for_status() | ||||||
|
|
||||||
| if resp.status == 204: | ||||||
| return None | ||||||
| return await resp.json() | ||||||
|
|
||||||
| async def _fetch_favorites_pls(self, network_key: str) -> str: | ||||||
| """Download favorites.pls from the listen.* host for this network.""" | ||||||
| domain = NETWORKS[network_key]["domain"] | ||||||
| listen_key = self.config.get_value("listen_key") | ||||||
| timeout = aiohttp.ClientTimeout(total=API_TIMEOUT) | ||||||
| params: dict[str, str] = {"listen_key": str(listen_key), **FAVORITES_PLS_EXTRA_PARAMS} | ||||||
| try: | ||||||
| for rel in FAVORITES_PLS_PATHS: | ||||||
| url = f"https://listen.{domain}/{rel}" | ||||||
| async with ( | ||||||
| self._throttler, | ||||||
| self.mass.http_session.get(url, params=params, timeout=timeout) as resp, | ||||||
| ): | ||||||
| if resp.status == 200: | ||||||
| return await resp.text() | ||||||
| if resp.status == 403: | ||||||
| self.logger.debug( | ||||||
| "%s: favorites.pls access denied for network %s (%s)", | ||||||
| self.domain, | ||||||
| network_key, | ||||||
| rel, | ||||||
| ) | ||||||
| return "" | ||||||
| if resp.status in (404, 406): | ||||||
| continue | ||||||
| if resp.status >= 500: | ||||||
| self.logger.debug( | ||||||
| "%s: favorites.pls HTTP %s for network %s (%s)", | ||||||
| self.domain, | ||||||
| resp.status, | ||||||
| network_key, | ||||||
| rel, | ||||||
| ) | ||||||
| return "" | ||||||
| resp.raise_for_status() | ||||||
| self.logger.debug( | ||||||
| "%s: favorites.pls not usable for network %s (tried %s)", | ||||||
| self.domain, | ||||||
| network_key, | ||||||
| ", ".join(FAVORITES_PLS_PATHS), | ||||||
| ) | ||||||
| return "" | ||||||
| except (aiohttp.ClientError, aiohttp.ServerTimeoutError) as err: | ||||||
| self.logger.debug( | ||||||
| "%s: favorites.pls request failed for network %s: %s", | ||||||
| self.domain, | ||||||
| network_key, | ||||||
| err, | ||||||
| ) | ||||||
| return "" | ||||||
|
|
||||||
| def _pls_url_basename(self, stream_url: str) -> str | None: | ||||||
| """Last path segment of a stream URL (stream mount / slug).""" | ||||||
| parsed = urlparse(stream_url.strip()) | ||||||
| path = parsed.path.strip("/") | ||||||
| if not path: | ||||||
| return None | ||||||
| return path.rsplit("/", maxsplit=1)[-1] | ||||||
|
|
||||||
| def _pls_basename_prefixes(self, network_key: str) -> tuple[str, ...]: | ||||||
| """Prefixes to strip from PLS stream basenames; longest match wins.""" | ||||||
| parts: list[str] = [] | ||||||
| parts.extend(NETWORK_PLS_SHORT_PREFIXES.get(network_key, ())) | ||||||
| parts.append(f"3rdparty_{network_key}_") | ||||||
| parts.append(f"{network_key}_") | ||||||
| # Dedupe, then longest first so e.g. ``3rdparty_jazzradio_`` beats ``jazzradio_``. | ||||||
| unique = dict.fromkeys(parts) | ||||||
| return tuple(sorted(unique, key=len, reverse=True)) | ||||||
|
|
||||||
| def _stream_url_to_channel_key(self, stream_url: str, network_key: str) -> str | None: | ||||||
| """Map a favorites.pls stream URL to a slug we can match to API channel ``key``.""" | ||||||
| basename = self._pls_url_basename(stream_url) | ||||||
| if not basename: | ||||||
| return None | ||||||
| for prefix in self._pls_basename_prefixes(network_key): | ||||||
| if basename.startswith(prefix): | ||||||
| key = basename[len(prefix) :] | ||||||
| if key: | ||||||
| return key | ||||||
| # Premium PLS often uses the API channel key alone (e.g. ``ambient``), not ``di_ambient``. | ||||||
| if _PLS_CHANNEL_SLUG_RE.fullmatch(basename): | ||||||
| return basename | ||||||
| return None | ||||||
|
|
||||||
| def _channel_data_for_pls_key( | ||||||
| self, | ||||||
| pls_key: str, | ||||||
| channels_by_key: dict[str, dict[str, Any]], | ||||||
| ) -> dict[str, Any] | None: | ||||||
| """Match a PLS-derived slug to channel JSON (handles ``*_aac`` / ``*_mp3`` mounts).""" | ||||||
| # Fast path: exact match with original casing. | ||||||
| if pls_key in channels_by_key: | ||||||
| return channels_by_key[pls_key] | ||||||
| # Build a lowercase-keyed view for case-insensitive matching. | ||||||
| lower_channels_by_key = {key.lower(): value for key, value in channels_by_key.items()} | ||||||
|
Comment on lines
+631
to
+632
|
||||||
| # Build a lowercase-keyed view for case-insensitive matching. | |
| lower_channels_by_key = {key.lower(): value for key, value in channels_by_key.items()} |
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.
We have a ready to use helper available for pls parsing at music_assistant.helpers.playlists (parse_pls). You could use it like:
def _parse_favorites_pls_channel_keys(self, pls_body: str, network_key: str) -> list[str]:
try:
items = parse_pls(pls_body)
except InvalidDataError:
return []
ordered = []
seen_keys = set()
for item in items:
channel_key = self._stream_url_to_channel_key(item.path, network_key)
if channel_key and channel_key not in seen_keys:
seen_keys.add(channel_key)
ordered.append(channel_key)
return ordered
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.
So 'pls' use that one ;-)
Uh oh!
There was an error while loading. Please reload this page.