Add MusicMe music provider#3393
Conversation
🔒 Dependency Security Report✅ No dependency changes detected in this PR. |
There was a problem hiding this comment.
Pull request overview
Adds a new MusicMe provider integration that supports search, browse, recommendations, library radios/playlists, and streaming via ticket-based URLs.
Changes:
- Introduces the MusicMe provider implementation (auth, API client/decryption, model parsing, streaming).
- Adds provider metadata (manifest) and icons for UI display.
- Implements browsing/recommendations surfaces backed by MusicMe dataservice endpoints.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| music_assistant/providers/musicme/manifest.json | Registers the new MusicMe provider and its metadata. |
| music_assistant/providers/musicme/icon.svg | Adds MusicMe colored icon asset. |
| music_assistant/providers/musicme/icon_monochrome.svg | Adds MusicMe monochrome icon asset. |
| music_assistant/providers/musicme/init.py | Implements the MusicMe provider: login, dataservice calls/decryption, search/browse, library, and streaming. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new MusicMe provider integration to Music Assistant, enabling search/browse/library/recommendations and streaming via MusicMe’s dataservice + ticketed streams.
Changes:
- Introduces the MusicMe provider implementation (auth, dataservice API access, item parsing, browsing, recommendations, streaming URLs).
- Adds provider manifest metadata and provider icons (standard + monochrome).
- Implements a web-search fallback when the dataservice search returns no results.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| music_assistant/providers/musicme/manifest.json | Registers the new MusicMe provider and its metadata for discovery/configuration. |
| music_assistant/providers/musicme/init.py | Core provider implementation: login, API calls/decryption, search/browse/library, and streaming ticket URL generation. |
| music_assistant/providers/musicme/icon.svg | Adds the provider icon asset. |
| music_assistant/providers/musicme/icon_monochrome.svg | Adds a monochrome icon variant asset. |
| _CLIENT_JSON = json.dumps( | ||
| {"type": "desktop-web", "context": "www.musicme.com", "app": "mmplayer"} |
There was a problem hiding this comment.
the MusicMe dataservice expects literal JSON in the query string. URL-encoding breaks the API.
| def _build_base_params(self) -> str: | ||
| """Build the common query string for dataservice API calls.""" | ||
| parts = ["format=json", f"partnerid={PARTNER_ID}", f"client={_CLIENT_JSON}"] | ||
| if self._user_id: | ||
| parts.append(f"userid={self._user_id}") | ||
| return "&".join(parts) |
There was a problem hiding this comment.
the MusicMe dataservice expects literal JSON in the query string. URL-encoding breaks the API.
| folder = RecommendationFolder( | ||
| name="A l'affiche", | ||
| item_id=f"{self.instance_id}_home", | ||
| provider=self.instance_id, | ||
| icon="mdi-star", | ||
| ) |
| radio = Radio( | ||
| item_id=radio_id, | ||
| provider=self.instance_id, | ||
| name=radio_obj.get("name", "Radio"), | ||
| provider_mappings={ | ||
| ProviderMapping( | ||
| item_id=radio_id, | ||
| provider_domain=self.domain, | ||
| provider_instance=self.instance_id, | ||
| ) | ||
| }, | ||
| ) |
| playlist = Playlist( | ||
| item_id=playlist_id, | ||
| provider=self.instance_id, | ||
| name=playlist_obj.get("name", playlist_obj.get("title", "Playlist")), | ||
| provider_mappings={ | ||
| ProviderMapping( | ||
| item_id=playlist_id, | ||
| provider_domain=self.domain, | ||
| provider_instance=self.instance_id, | ||
| ) | ||
| }, | ||
| ) |
|
|
||
| SUPPORTED_FEATURES = { | ||
| ProviderFeature.LIBRARY_PLAYLISTS, | ||
| ProviderFeature.LIBRARY_RADIOS, |
There was a problem hiding this comment.
This should only be declared if the user creates a curated set of radio stations that are pulled from the provider.
| """Handle async initialization of the provider.""" | ||
| if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_PASSWORD): | ||
| msg = "Invalid login credentials" | ||
| raise LoginFailed(msg) |
There was a problem hiding this comment.
| raise LoginFailed(msg) | |
| raise SetupFailedError(msg) |
MIssing credentials is not a login failure
| def _session(self) -> aiohttp.ClientSession: | ||
| """Return the provider's dedicated HTTP session.""" | ||
| if self._http_session is None or self._http_session.closed: | ||
| self._http_session = aiohttp.ClientSession() |
There was a problem hiding this comment.
Maybe I'm wrong but this seems a bit of a hack to address an error state by restarting a closed session?
|
|
||
| # ---- search ---- | ||
|
|
||
| @use_cache(3600 * 24) |
There was a problem hiding this comment.
This would mean a user would see the same search results for 24 hours regardless of what they search for
|
|
||
| if not result.artists and not result.albums and not result.tracks: | ||
| self.logger.debug("API search empty, trying web search fallback") | ||
| web_result = await self._web_search_fallback(search_query, media_types, limit) |
There was a problem hiding this comment.
Why is this needed? What is wrong with the API?
|
|
||
| # ---- library ---- | ||
|
|
||
| async def get_library_radios(self) -> AsyncGenerator[Radio, None]: |
There was a problem hiding this comment.
Related to LIBRARY_RADIOS. This method is only for when the provider allows the user to create a curated list of stations (for example TuneIn). If the user selects stations to be added to the library from a large list then that is what browse is for. If MusicMe allows user to save a list of favourite stations then all good.
| data = await self._api_get(f"/airplay/{prov_radio_id}?resources=tracks") | ||
| if data and "item" in data: | ||
| return self._parse_radio(data["item"]) | ||
| async for radio in self.get_library_radios(): |
There was a problem hiding this comment.
Why would this be required and why would you not raise MediaNotFoundError immediately?
| if not data: | ||
| return | ||
| items: Any = data.get("results", {}) | ||
| if isinstance(items, dict) and "items" in items: |
There was a problem hiding this comment.
What is going on here. Why dont you know what the API will return?
| if art_obj.get("id"): | ||
| track.artists.append(self._parse_artist(art_obj)) | ||
| if track_obj.get("album"): | ||
| track.album = self._parse_album( |
There was a problem hiding this comment.
I think an ItemMapping should be created here as there is no data to populate the album?
| url = f"{url}{sep}{self._build_base_params()}" | ||
|
|
||
| self.logger.debug("GET %s", endpoint.split("?")[0]) | ||
| async with self._session.get(url) as response: |
There was a problem hiding this comment.
There is no error handling here if there is some sort of connection problem?
|
Also this is very long for one file. We generally like to see the following structure and also install and run pre-commit before pushing any changes. Currently this provider has a number of mypy issues. |
Summary
Add a new music provider for MusicMe (musicme.com), a French legal music streaming service operated by ApachNetwork with a catalogue of 13M+ tracks from major and independent labels.
Features
Supported features
LIBRARY_PLAYLISTS,LIBRARY_RADIOSSEARCHARTIST_ALBUMS,ARTIST_TOPTRACKSBROWSE,RECOMMENDATIONSNotes