Apple music provider (#1315)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Wed, 22 May 2024 06:07:15 +0000 (08:07 +0200)
committerGitHub <noreply@github.com>
Wed, 22 May 2024 06:07:15 +0000 (08:07 +0200)
* Added encrypted http stream type

* Add library functions

* Add playback

* Add playback

* Precommit

* Add decryption key caching, notes on albums and artist and provider icon.

* Test download cdm files

* Test download cdm files

* Test download cdm files

* Linting fixes

* Update gitignore

* Pin m3u8

* Pin m3u8

* Pin m3u8

* Add similar tracks

* Add search

* Add search

* Update music_assistant/server/providers/apple_music/__init__.py

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
* Fix feedback

* Rework playlist/audio helpers to support m3u8 keys.

* Add throttling to get/post functions.

---------

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
13 files changed:
.github/workflows/release.yml
Dockerfile
music_assistant/common/models/enums.py
music_assistant/common/models/streamdetails.py
music_assistant/server/controllers/streams.py
music_assistant/server/helpers/app_vars.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/playlists.py
music_assistant/server/providers/apple_music/__init__.py [new file with mode: 0644]
music_assistant/server/providers/apple_music/bin/README.md [new file with mode: 0644]
music_assistant/server/providers/apple_music/icon.svg [new file with mode: 0644]
music_assistant/server/providers/apple_music/manifest.json [new file with mode: 0644]
requirements_all.txt

index cfd697b01dc96df681b9930b72e5d583e815435e..c931a3d2fdf8ed05dd8f6ad3d60e47ed3636edc5 100644 (file)
@@ -69,6 +69,14 @@ jobs:
     needs: build-and-publish-pypi
     steps:
       - uses: actions/checkout@v4.1.4
+      - name: Download Widevine CDM client files from private repository
+        shell: bash
+        env:
+          TOKEN: ${{ secrets.PRIVILEGED_GITHUB_TOKEN }}
+        run: |
+          mkdir -p widevine_cdm && cd widevine_cdm
+          curl -OJ -H "Authorization: token ${TOKEN}" https://raw.githubusercontent.com/music-assistant/appvars/main/widevine_cdm_client/private_key.pem
+          curl -OJ -H "Authorization: token ${TOKEN}" https://raw.githubusercontent.com/music-assistant/appvars/main/widevine_cdm_client/client_id.bin
       - name: Log in to the GitHub container registry
         uses: docker/login-action@v3.1.0
         with:
index 9f9d70b32948746487cf494c20104b33b24f41aa..961874bc3f6c692bc0ae60c9d6de1e0d3a7c48ca 100644 (file)
@@ -72,6 +72,9 @@ RUN set -x \
     && rm -rf /tmp/* \
     && rm -rf /var/lib/apt/lists/*
 
+# Copy widevine client files to container
+RUN mkdir -p /usr/local/bin/widevine_cdm
+COPY widevine_cdm/* /usr/local/bin/widevine_cdm/
 
 # https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/syntax.md#build-mounts-run---mount
 # Install all built wheels
index ddb6c2b2c594f3104b7c49ef44f141967dd95123..52018f84fb36b3f82ddcd74dafce766e89cb761c 100644 (file)
@@ -405,6 +405,7 @@ class StreamType(StrEnum):
     """Enum for the type of streamdetails."""
 
     HTTP = "http"  # regular http stream
+    ENCRYPTED_HTTP = "encrypted_http"  # encrypted http stream
     HLS = "hls"  # http HLS stream
     ICY = "icy"  # http stream with icy metadata
     LOCAL_FILE = "local_file"
index c96ce9cbba6ae183b0b774f7a75d9d405eb2d51f..d33a25ce0e5bcf49d537b9d0abdca0e0bc92968a 100644 (file)
@@ -38,6 +38,7 @@ class StreamDetails(DataClassDictMixin):
     media_type: MediaType = MediaType.TRACK
     stream_type: StreamType = StreamType.CUSTOM
     path: str | None = None
+    decryption_key: str | None = None
 
     # stream_title: radio streams can optionally set this field
     stream_title: str | None = None
index 025562c5add04d324c44c37d10590fb3d10f001e..443f47403f9d8d91e84e9c8e36feaa2a4d0af12a 100644 (file)
@@ -742,6 +742,9 @@ class StreamsController(CoreController):
             audio_source = get_hls_stream(
                 self.mass, streamdetails.path, streamdetails, streamdetails.seek_position
             )
+        elif streamdetails.stream_type == StreamType.ENCRYPTED_HTTP:
+            audio_source = streamdetails.path
+            extra_input_args += ["-decryption_key", streamdetails.decryption_key]
         else:
             audio_source = streamdetails.path
             if streamdetails.seek_position:
index b754cf181a2e8b2f6b8c27eff91512b8f629d69d..65ec57705b85b1eeb39b8c790a3429d47583021e 100644 (file)
@@ -1,5 +1,5 @@
 # pylint: skip-file
 # fmt: off
 # flake8: noqa
-# type: ignore
-(lambda __g: [(lambda __mod: [[[None for __g['app_var'], app_var.__name__ in [(lambda index: (lambda __l: [[AV(aap(__l['var'].encode()).decode()) for __l['var'] in [(vars.split('acb2')[__l['index']][::-1])]][0] for __l['index'] in [(index)]][0])({}), 'app_var')]][0] for __g['vars'] in [('3YTNyUDOyQTOacb2=EmN5M2YjdzMhljYzYzYhlDMmFGNlVTOmNDZwMzNxYzNacb2=UDMzEGOyADO1QWO5kDNygTMlJGN5QzNzIWOmZTOiVmMacb2yMTNzITNacb2=UDZhJmMldTZ3QTY4IjZ3kTNxYjN0czNwI2YxkTM5MjNacb2==QMh5WOmZnewM2d4UDblRzZacb20QzMwAjNacb2=QzNiRTO3EjMjFzMldjY3QTMwEDMwADMiNWZ5UWO3UWM')]][0] for __g['aap'] in [(__mod.b64decode)]][0])(__import__('base64', __g, __g, ('b64decode',), 0)) for __g['AV'] in [((lambda b, d: d.get('__metaclass__', getattr(b[0], '__class__', type(b[0])))('AV', b, d))((str,), (lambda __l: [__l for __l['__repr__'], __l['__repr__'].__name__ in [(lambda self: (lambda __l: [__name__ for __l['self'] in [(self)]][0])({}), '__repr__')]][0])({'__module__': __name__})))]][0])(globals())
+# ruff: noqa
+(lambda __g: [(lambda __mod: [[[None for __g['app_var'], app_var.__name__ in [(lambda index: (lambda __l: [[AV(aap(__l['var'].encode()).decode()) for __l['var'] in [(vars.split('acb2')[__l['index']][::(-1)])]][0] for __l['index'] in [(index)]][0])({}), 'app_var')]][0] for __g['vars'] in [('3YTNyUDOyQTOacb2=EmN5M2YjdzMhljYzYzYhlDMmFGNlVTOmNDZwMzNxYzNacb2=UDMzEGOyADO1QWO5kDNygTMlJGN5QzNzIWOmZTOiVmMacb2yMTNzITNacb2=UDZhJmMldTZ3QTY4IjZ3kTNxYjN0czNwI2YxkTM5MjNacb2==QMh5WOmZnewM2d4UDblRzZacb20QzMwAjNacb2=QzNiRTO3EjMjFzMldjY3QTMwEDMwADMiNWZ5UWO3UWMacb2B5EV1EGR3hncSxERwZFZ61CW3sUYxRXVSt0RaJmc6ZzbPJmZQVjSBZkeKZjb5VVVUVVYDlXZ3JVc6hXeDhGZJBXdBhVVxYWWKRzRfZjW2M2ZU1mT3RnL54kaOhXVU50drpWTzUkaPlWQIVGbKNET6lFVNRzYq1keFpnT49maJBjRXFWa3lWS0MXVUpXSU5URWRlT1kUaPlWTzMGcKlXZuElZpFVMWtkSp9UaBhVZwo0QMl2Yq1UevtWVyEFbUNTWqlkNJNkWwRXbJNXSp5UMJpXVGpUaPl2YHJGaKlXZ')]][0] for __g['aap'] in [(__mod.b64decode)]][0])(__import__('base64', __g, __g, ('b64decode',), 0)) for __g['AV'] in [((lambda b, d: d.get('__metaclass__', getattr(b[0], '__class__', type(b[0])))('AV', b, d))((str,), (lambda __l: [__l for __l['__repr__'], __l['__repr__'].__name__ in [(lambda self: (lambda __l: [__name__ for __l['self'] in [(self)]][0])({}), '__repr__')]][0])({'__module__': __name__})))]][0])(globals())
index 42011923f3397b02837607ed3851b55ac6e1733f..4e95751445000bea0862341116205b43e07a6a2b 100644 (file)
@@ -46,6 +46,7 @@ from music_assistant.constants import (
 from music_assistant.server.helpers.playlists import (
     HLS_CONTENT_TYPES,
     IsHLSPlaylist,
+    PlaylistItem,
     fetch_playlist,
     parse_m3u,
 )
@@ -495,15 +496,17 @@ async def resolve_radio_stream(mass: MusicAssistant, url: str) -> tuple[str, boo
             or headers.get("content-type") == "audio/x-mpegurl"
         ):
             # url is playlist, we need to unfold it
-            try:
-                for line in await fetch_playlist(mass, resolved_url):
-                    if not line.is_url:
-                        continue
-                    # unfold first url of playlist
-                    return await resolve_radio_stream(mass, line.path)
-                raise InvalidDataError("No content found in playlist")
-            except IsHLSPlaylist:
-                is_hls = True
+            substreams = await fetch_playlist(mass, resolved_url)
+            if not any(x for x in substreams if x.length):
+                try:
+                    for line in substreams:
+                        if not line.is_url:
+                            continue
+                        # unfold first url of playlist
+                        return await resolve_radio_stream(mass, line.path)
+                    raise InvalidDataError("No content found in playlist")
+                except IsHLSPlaylist:
+                    is_hls = True
 
     except Exception as err:
         LOGGER.warning("Error while parsing radio URL %s: %s", url, err)
@@ -569,7 +572,8 @@ async def get_hls_stream(
     # we need to move the substream selection into the loop below and make it
     # bandwidth aware. For now we just assume domestic high bandwidth where
     # the user wants the best quality possible at all times.
-    substream_url = await get_hls_substream(mass, url)
+    playlist_item = await get_hls_substream(mass, url)
+    substream_url = playlist_item.path
     seconds_skipped = 0
     empty_loops = 0
     while True:
@@ -651,7 +655,7 @@ async def get_hls_stream(
 async def get_hls_substream(
     mass: MusicAssistant,
     url: str,
-) -> str:
+) -> PlaylistItem:
     """Select the (highest quality) HLS substream for given HLS playlist/URL."""
     timeout = ClientTimeout(total=0, connect=30, sock_read=5 * 60)
     # fetch master playlist and select (best) child playlist
@@ -661,18 +665,15 @@ async def get_hls_substream(
         charset = resp.charset or "utf-8"
         master_m3u_data = await resp.text(charset)
     substreams = parse_m3u(master_m3u_data)
-    if any(x for x in substreams if x.length):
-        # the url we got is already a substream
-        return url
-    # sort substreams on best quality (highest bandwidth)
-    substreams.sort(key=lambda x: int(x.stream_info.get("BANDWIDTH", "0")), reverse=True)
+    # sort substreams on best quality (highest bandwidth) when available
+    if any(x for x in substreams if x.stream_info):
+        substreams.sort(key=lambda x: int(x.stream_info.get("BANDWIDTH", "0")), reverse=True)
     substream = substreams[0]
-    substream_url = substream.path
-    if not substream_url.startswith("http"):
+    if not substream.path.startswith("http"):
         # path is relative, stitch it together
         base_path = url.rsplit("/", 1)[0]
-        substream_url = base_path + "/" + substream.path
-    return substream_url
+        substream.path = base_path + "/" + substream.path
+    return substream
 
 
 async def get_http_stream(
index 023646fef9d1fdff8e5a68677a091a5697baf966..62a7a8fa5bbffd9d9d7ca61115332d958b7e5ff2 100644 (file)
@@ -37,6 +37,7 @@ class PlaylistItem:
     length: str | None = None
     title: str | None = None
     stream_info: dict[str, str] | None = None
+    key: str | None = None
 
     @property
     def is_url(self) -> bool:
@@ -59,6 +60,7 @@ def parse_m3u(m3u_data: str) -> list[PlaylistItem]:
     length = None
     title = None
     stream_info = None
+    key = None
 
     for line in m3u_lines:
         line = line.strip()  # noqa: PLW2901
@@ -78,16 +80,19 @@ def parse_m3u(m3u_data: str) -> list[PlaylistItem]:
                     continue
                 kev_value_parts = part.strip().split("=")
                 stream_info[kev_value_parts[0]] = kev_value_parts[1]
+        elif line.startswith("#EXT-X-KEY:"):
+            key = line.split(",URI=")[1].strip('"')
         elif line.startswith("#"):
             # Ignore other extensions
             continue
         elif len(line) != 0:
-            # Get song path from all other, non-blank lines
             if "%20" in line:
                 # apparently VLC manages to encode spaces in filenames
                 line = line.replace("%20", " ")  # noqa: PLW2901
             playlist.append(
-                PlaylistItem(path=line, length=length, title=title, stream_info=stream_info)
+                PlaylistItem(
+                    path=line, length=length, title=title, stream_info=stream_info, key=key
+                )
             )
             # reset the song variables so it doesn't use the same EXTINF more than once
             length = None
diff --git a/music_assistant/server/providers/apple_music/__init__.py b/music_assistant/server/providers/apple_music/__init__.py
new file mode 100644 (file)
index 0000000..b5c2b8d
--- /dev/null
@@ -0,0 +1,702 @@
+"""Apple Music musicprovider support for MusicAssistant."""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+from typing import TYPE_CHECKING, Any
+
+import aiofiles
+from pywidevine import PSSH, Cdm, Device, DeviceTypes
+from pywidevine.license_protocol_pb2 import WidevinePsshData
+
+from music_assistant.common.helpers.json import json_loads
+from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant.common.models.enums import (
+    ConfigEntryType,
+    ExternalID,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant.common.models.errors import MediaNotFoundError, ResourceTemporarilyUnavailable
+from music_assistant.common.models.media_items import (
+    Album,
+    AlbumType,
+    Artist,
+    AudioFormat,
+    ContentType,
+    ImageType,
+    ItemMapping,
+    MediaItemImage,
+    MediaItemType,
+    MediaType,
+    Playlist,
+    ProviderMapping,
+    SearchResults,
+    Track,
+)
+from music_assistant.common.models.streamdetails import StreamDetails
+from music_assistant.constants import CONF_PASSWORD
+
+# pylint: disable=no-name-in-module
+from music_assistant.server.helpers.app_vars import app_var
+from music_assistant.server.helpers.audio import get_hls_substream
+from music_assistant.server.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
+from music_assistant.server.models.music_provider import MusicProvider
+
+if TYPE_CHECKING:
+    from collections.abc import AsyncGenerator
+
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+SUPPORTED_FEATURES = (
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.ARTIST_TOPTRACKS,
+    ProviderFeature.SIMILAR_TRACKS,
+)
+
+DEVELOPER_TOKEN = app_var(8)
+WIDEVINE_BASE_PATH = "/usr/local/bin/widevine_cdm"
+DECRYPT_CLIENT_ID_FILENAME = "client_id.bin"
+DECRYPT_PRIVATE_KEY_FILENAME = "private_key.pem"
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = AppleMusicProvider(mass, manifest, config)
+    await prov.handle_async_init()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # ruff: noqa: ARG001
+    return (
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Music user token",
+            required=True,
+        ),
+    )
+
+
+class AppleMusicProvider(MusicProvider):
+    """Implementation of an Apple Music MusicProvider."""
+
+    _music_user_token: str | None = None
+    _storefront: str | None = None
+    _decrypt_client_id: bytes | None = None
+    _decrypt_private_key: bytes | None = None
+    # rate limiter needs to be specified on provider-level,
+    # so make it an instance attribute
+    throttler = ThrottlerManager(rate_limit=1, period=2)
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        self._music_user_token = self.config.get_value(CONF_PASSWORD)
+        self._storefront = await self._get_user_storefront()
+        async with aiofiles.open(
+            os.path.join(WIDEVINE_BASE_PATH, DECRYPT_CLIENT_ID_FILENAME), "rb"
+        ) as _file:
+            self._decrypt_client_id = await _file.read()
+        async with aiofiles.open(
+            os.path.join(WIDEVINE_BASE_PATH, DECRYPT_PRIVATE_KEY_FILENAME), "rb"
+        ) as _file:
+            self._decrypt_private_key = await _file.read()
+
+    @property
+    def supported_features(self) -> tuple[ProviderFeature, ...]:
+        """Return the features supported by this Provider."""
+        return SUPPORTED_FEATURES
+
+    async def search(
+        self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
+    ) -> SearchResults:
+        """Perform search on musicprovider.
+
+        :param search_query: Search query.
+        :param media_types: A list of media_types to include. All types if None.
+        :param limit: Number of items to return in the search (per type).
+        """
+        endpoint = f"catalog/{self._storefront}/search"
+        # Apple music has a limit of 25 items for the search endpoint
+        limit = min(limit, 25)
+        searchresult = SearchResults()
+        searchtypes = []
+        if MediaType.ARTIST in media_types:
+            searchtypes.append("artists")
+        if MediaType.ALBUM in media_types:
+            searchtypes.append("albums")
+        if MediaType.TRACK in media_types:
+            searchtypes.append("songs")
+        if MediaType.PLAYLIST in media_types:
+            searchtypes.append("playlists")
+        if not searchtypes:
+            return searchresult
+        searchtype = ",".join(searchtypes)
+        search_query = search_query.replace("'", "")
+        response = await self._get_data(endpoint, term=search_query, types=searchtype, limit=limit)
+        if "artists" in response["results"]:
+            searchresult.artists += [
+                self._parse_artist(item) for item in response["results"]["artists"]["data"]
+            ]
+        if "albums" in response["results"]:
+            searchresult.albums += [
+                self._parse_album(item) for item in response["results"]["albums"]["data"]
+            ]
+        if "songs" in response["results"]:
+            searchresult.tracks += [
+                self._parse_track(item) for item in response["results"]["songs"]["data"]
+            ]
+        if "playlists" in response["results"]:
+            searchresult.playlists += [
+                self._parse_playlist(item) for item in response["results"]["playlists"]["data"]
+            ]
+        return searchresult
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve library artists from spotify."""
+        endpoint = "me/library/artists"
+        for item in await self._get_all_items(endpoint, include="catalog", extend="editorialNotes"):
+            if item and item["id"]:
+                yield self._parse_artist(item)
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve library albums from the provider."""
+        endpoint = "me/library/albums"
+        for item in await self._get_all_items(
+            endpoint, include="catalog,artists", extend="editorialNotes"
+        ):
+            if item and item["id"]:
+                yield self._parse_album(item)
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from the provider."""
+        endpoint = "me/library/songs"
+        for item in await self._get_all_items(endpoint, include="artists,albums,catalog"):
+            if item and item["id"]:
+                yield self._parse_track(item)
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve playlists from the provider."""
+        endpoint = "me/library/playlists"
+        for item in await self._get_all_items(endpoint):
+            if item and item["id"]:
+                yield self._parse_playlist(item)
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        """Get full artist details by id."""
+        endpoint = f"catalog/{self._storefront}/artists/{prov_artist_id}"
+        response = await self._get_data(endpoint, extend="editorialNotes")
+        return self._parse_artist(response["data"][0])
+
+    async def get_album(self, prov_album_id) -> Album:
+        """Get full album details by id."""
+        endpoint = f"catalog/{self._storefront}/albums/{prov_album_id}"
+        response = await self._get_data(endpoint)
+        return self._parse_album(response["data"][0])
+
+    async def get_track(self, prov_track_id) -> Track:
+        """Get full track details by id."""
+        endpoint = f"catalog/{self._storefront}/songs/{prov_track_id}"
+        response = await self._get_data(endpoint, include="artists")
+        return self._parse_track(response["data"][0])
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        """Get full playlist details by id."""
+        endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}"
+        response = await self._get_data(endpoint)
+        return self._parse_playlist(response["data"][0])
+
+    async def get_album_tracks(self, prov_album_id) -> list[Track]:
+        """Get all album tracks for given album id."""
+        endpoint = f"catalog/{self._storefront}/albums/{prov_album_id}/tracks"
+        response = await self._get_data(endpoint, include="artists")
+        return [self._parse_track(track) for track in response["data"] if track["id"]]
+
+    async def get_playlist_tracks(
+        self, prov_playlist_id, offset, limit
+    ) -> AsyncGenerator[Track, None]:
+        """Get all playlist tracks for given playlist id."""
+        # TODO: Import paging
+        if self._is_catalog_id(prov_playlist_id):
+            endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}/tracks"
+        else:
+            endpoint = f"me/library/playlists/{prov_playlist_id}/tracks"
+        count = 1
+        result = []
+        for track in await self._get_all_items(endpoint, include="artists,catalog"):
+            if track and track["id"]:
+                parsed_track = self._parse_track(track)
+                parsed_track.position = count
+                result.append(parsed_track)
+                count += 1
+        return result
+
+    async def get_artist_albums(self, prov_artist_id) -> list[Album]:
+        """Get a list of all albums for the given artist."""
+        endpoint = f"catalog/{self._storefront}/artists/{prov_artist_id}/albums"
+        response = await self._get_data(endpoint)
+        return [self._parse_album(album) for album in response["data"] if album["id"]]
+
+    async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
+        """Get a list of 10 most popular tracks for the given artist."""
+        endpoint = f"catalog/{self._storefront}/artists/{prov_artist_id}/view/top-songs"
+        response = await self._get_data(endpoint)
+        return [self._parse_track(track) for track in response["data"] if track["id"]]
+
+    async def library_add(self, item: MediaItemType):
+        """Add item to library."""
+        raise NotImplementedError("Not implemented!")
+
+    async def library_remove(self, prov_item_id, media_type: MediaType):
+        """Remove item from library."""
+        raise NotImplementedError("Not implemented!")
+
+    async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]):
+        """Add track(s) to playlist."""
+        raise NotImplementedError("Not implemented!")
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        raise NotImplementedError("Not implemented!")
+
+    async def get_similar_tracks(self, prov_track_id, limit=25) -> list[Track]:
+        """Retrieve a dynamic list of tracks based on the provided item."""
+        # Note, Apple music does not have an official endpoint for similar tracks.
+        # We will use the next-tracks endpoint to get a list of tracks that are similar to the
+        # provided track. However, Apple music only provides 2 tracks at a time, so we will
+        # need to call the endpoint multiple times. Therefore, set a limit to 6 to prevent
+        # flooding the apple music api.
+        limit = 6
+        endpoint = f"me/stations/next-tracks/ra.{prov_track_id}"
+        found_tracks = []
+        while len(found_tracks) < limit:
+            response = await self._post_data(endpoint, include="artists")
+            if not response or "data" not in response:
+                break
+            for track in response["data"]:
+                if track and track["id"]:
+                    found_tracks.append(self._parse_track(track))
+        return found_tracks
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        stream_metadata = await self._fetch_song_stream_metadata(item_id)
+        license_url = stream_metadata["hls-key-server-url"]
+        stream_url, uri = await self._parse_stream_url_and_uri(stream_metadata["assets"])
+        key_id = base64.b64decode(uri.split(",")[1])
+        return StreamDetails(
+            item_id=item_id,
+            provider=self.instance_id,
+            audio_format=AudioFormat(
+                content_type=ContentType.UNKNOWN,
+            ),
+            stream_type=StreamType.ENCRYPTED_HTTP,
+            path=stream_url,
+            decryption_key=await self._get_decryption_key(license_url, key_id, uri, item_id),
+        )
+
+    def _parse_artist(self, artist_obj):
+        """Parse artist object to generic layout."""
+        relationships = artist_obj.get("relationships", {})
+        if artist_obj.get("type") == "library-artists" and relationships["catalog"]["data"] != []:
+            artist_id = relationships["catalog"]["data"][0]["id"]
+            attributes = relationships["catalog"]["data"][0]["attributes"]
+        elif "attributes" in artist_obj:
+            artist_id = artist_obj["id"]
+            attributes = artist_obj["attributes"]
+        else:
+            artist_id = artist_obj["id"]
+            attributes = {}
+        artist = Artist(
+            item_id=artist_id,
+            name=attributes.get("name"),
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=artist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=attributes.get("url"),
+                )
+            },
+        )
+        if artwork := attributes.get("artwork"):
+            artist.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
+                    provider=self.instance_id,
+                    remotely_accessible=True,
+                )
+            ]
+        if genres := attributes.get("genreNames"):
+            artist.metadata.genres = set(genres)
+        if notes := attributes.get("editorialNotes"):
+            artist.metadata.description = notes.get("standard") or notes.get("short")
+        return artist
+
+    def _parse_album(self, album_obj: dict):
+        """Parse album object to generic layout."""
+        relationships = album_obj.get("relationships", {})
+        response_type = album_obj.get("type")
+        if response_type == "library-albums" and relationships["catalog"]["data"] != []:
+            album_id = relationships.get("catalog", {})["data"][0]["id"]
+            attributes = relationships.get("catalog", {})["data"][0]["attributes"]
+        elif "attributes" in album_obj:
+            album_id = album_obj["id"]
+            attributes = album_obj["attributes"]
+        else:
+            album_id = album_obj["id"]
+            attributes = {}
+        album = Album(
+            item_id=album_id,
+            provider=self.domain,
+            name=attributes.get("name"),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=album_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=attributes.get("url"),
+                    available=attributes.get("playParams", {}).get("id") is not None,
+                )
+            },
+        )
+        if artists := relationships.get("artists"):
+            album.artists = [self._parse_artist(artist) for artist in artists["data"]]
+        if release_date := attributes.get("releaseDate"):
+            album.year = int(release_date.split("-")[0])
+        if genres := attributes.get("genreNames"):
+            album.metadata.genres = set(genres)
+        if artwork := attributes.get("artwork"):
+            album.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
+                    provider=self.instance_id,
+                    remotely_accessible=True,
+                )
+            ]
+        if album_copyright := attributes.get("copyright"):
+            album.metadata.copyright = album_copyright
+        if record_label := attributes.get("recordLabel"):
+            album.metadata.label = record_label
+        if upc := attributes.get("upc"):
+            album.external_ids.add((ExternalID.BARCODE, "0" + upc))
+        if notes := attributes.get("editorialNotes"):
+            album.metadata.description = notes.get("standard") or notes.get("short")
+        if content_rating := attributes.get("contentRating"):
+            album.metadata.explicit = content_rating == "explicit"
+        album_type = AlbumType.ALBUM
+        if attributes.get("isSingle"):
+            album_type = AlbumType.SINGLE
+        elif attributes.get("isCompilation"):
+            album_type = AlbumType.COMPILATION
+        album.album_type = album_type
+        return album
+
+    def _parse_track(
+        self,
+        track_obj: dict[str, Any],
+    ) -> Track:
+        """Parse track object to generic layout."""
+        relationships = track_obj.get("relationships", {})
+        if track_obj.get("type") == "library-songs" and relationships["catalog"]["data"] != []:
+            track_id = relationships.get("catalog", {})["data"][0]["id"]
+            attributes = relationships.get("catalog", {})["data"][0]["attributes"]
+        elif "attributes" in track_obj:
+            track_id = track_obj["id"]
+            attributes = track_obj["attributes"]
+        else:
+            track_id = track_obj["id"]
+            attributes = {}
+        track = Track(
+            item_id=track_id,
+            provider=self.domain,
+            name=attributes.get("name"),
+            duration=attributes.get("durationInMillis", 0) / 1000,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(content_type=ContentType.AAC),
+                    url=attributes.get("url"),
+                    available=attributes.get("playParams", {}).get("id") is not None,
+                )
+            },
+        )
+        if disc_number := attributes.get("discNumber"):
+            track.disc_number = disc_number
+        if track_number := attributes.get("trackNumber"):
+            track.track_number = track_number
+        if artists := relationships.get("artists"):
+            track.artists = [self._parse_artist(artist) for artist in artists["data"]]
+        # 'Similar tracks' do not provide full artist details
+        elif artist := attributes.get("artistName"):
+            track.artists = [
+                ItemMapping(
+                    media_type=MediaType.ARTIST,
+                    item_id=artist,
+                    provider=self.instance_id,
+                    name=artist,
+                )
+            ]
+        if albums := relationships.get("albums"):
+            track.album = self._parse_album(albums["data"][0])
+        if artwork := attributes.get("artwork"):
+            track.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
+                    provider=self.instance_id,
+                    remotely_accessible=True,
+                )
+            ]
+        if genres := attributes.get("genreNames"):
+            track.metadata.genres = set(genres)
+        if composers := attributes.get("composerName"):
+            track.metadata.performers = set(composers.split(", "))
+        if isrc := attributes.get("isrc"):
+            track.external_ids.add((ExternalID.ISRC, isrc))
+        return track
+
+    def _parse_playlist(self, playlist_obj):
+        """Parse Apple Music playlist object to generic layout."""
+        attributes = playlist_obj["attributes"]
+        playlist = Playlist(
+            item_id=playlist_obj["id"],
+            provider=self.domain,
+            name=attributes["name"],
+            owner=attributes.get("curatorName", "me"),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=attributes.get("url"),
+                )
+            },
+        )
+        if artwork := attributes.get("artwork"):
+            playlist.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
+                    provider=self.instance_id,
+                    remotely_accessible=True,
+                )
+            ]
+        if description := attributes.get("description"):
+            playlist.metadata.description = description.get("standard")
+        playlist.is_editable = attributes.get("canEdit", False)
+        if checksum := attributes.get("lastModifiedDate"):
+            playlist.metadata.cache_checksum = checksum
+        return playlist
+
+    async def _get_all_items(self, endpoint, key="data", **kwargs) -> list[dict]:
+        """Get all items from a paged list."""
+        limit = 50
+        offset = 0
+        all_items = []
+        while True:
+            kwargs["limit"] = limit
+            kwargs["offset"] = offset
+            result = await self._get_data(endpoint, **kwargs)
+            offset += limit
+            if not result or key not in result or not result[key]:
+                break
+            all_items += result[key]
+            if len(result[key]) < limit:
+                break
+        return all_items
+
+    @throttle_with_retries
+    async def _get_data(self, endpoint, **kwargs) -> dict[str, Any]:
+        """Get data from api."""
+        url = f"https://api.music.apple.com/v1/{endpoint}"
+        headers = {"Authorization": f"Bearer {DEVELOPER_TOKEN}"}
+        headers["Music-User-Token"] = self._music_user_token
+        async with (
+            self.mass.http_session.get(
+                url, headers=headers, params=kwargs, ssl=True, timeout=120
+            ) as response,
+        ):
+            # Convert HTTP errors to exceptions
+            if response.status == 404:
+                raise MediaNotFoundError(f"{endpoint} not found")
+            if response.status == 429:
+                # Debug this for now to see if the response headers give us info about the
+                # backoff time. There is no documentation on this.
+                self.logger.debug("Apple Music Rate Limiter. Headers: %s", response.headers)
+                raise ResourceTemporarilyUnavailable("Apple Music Rate Limiter")
+            response.raise_for_status()
+            return await response.json(loads=json_loads)
+
+    async def _delete_data(self, endpoint, data=None, **kwargs) -> str:
+        """Delete data from api."""
+        raise NotImplementedError("Not implemented!")
+
+    async def _put_data(self, endpoint, data=None, **kwargs) -> str:
+        """Put data on api."""
+        raise NotImplementedError("Not implemented!")
+
+    @throttle_with_retries
+    async def _post_data(self, endpoint, data=None, **kwargs) -> str:
+        """Post data on api."""
+        url = f"https://api.music.apple.com/v1/{endpoint}"
+        headers = {"Authorization": f"Bearer {DEVELOPER_TOKEN}"}
+        headers["Music-User-Token"] = self._music_user_token
+        async with (
+            self.mass.http_session.post(
+                url, headers=headers, params=kwargs, json=data, ssl=True, timeout=120
+            ) as response,
+        ):
+            # Convert HTTP errors to exceptions
+            if response.status == 404:
+                raise MediaNotFoundError(f"{endpoint} not found")
+            if response.status == 429:
+                # Debug this for now to see if the response headers give us info about the
+                # backoff time. There is no documentation on this.
+                self.logger.debug("Apple Music Rate Limiter. Headers: %s", response.headers)
+                raise ResourceTemporarilyUnavailable("Apple Music Rate Limiter")
+            response.raise_for_status()
+            return await response.json(loads=json_loads)
+
+    async def _get_user_storefront(self) -> str:
+        """Get the user's storefront."""
+        locale = self.mass.metadata.locale.replace("_", "-")
+        language = locale.split("-")[0]
+        result = await self._get_data("me/storefront", l=language)
+        return result["data"][0]["id"]
+
+    def _is_catalog_id(self, catalog_id: str) -> bool:
+        """Check if input is a catalog id, or a library id."""
+        return catalog_id.isnumeric()
+
+    async def _fetch_song_stream_metadata(self, song_id: str) -> str:
+        """Get the stream URL for a song from Apple Music."""
+        playback_url = "https://play.music.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
+        data = {
+            "salableAdamId": song_id,
+        }
+        async with self.mass.http_session.post(
+            playback_url, headers=self._get_decryption_headers(), json=data, ssl=True
+        ) as response:
+            response.raise_for_status()
+            content = await response.json(loads=json_loads)
+            return content["songList"][0]
+
+    async def _parse_stream_url_and_uri(self, stream_assets: list[dict]) -> str:
+        """Parse the Stream URL and Key URI from the song."""
+        ctrp256_urls = [asset["URL"] for asset in stream_assets if asset["flavor"] == "28:ctrp256"]
+        if len(ctrp256_urls) == 0:
+            raise MediaNotFoundError("No ctrp256 URL found for song.")
+        playlist_item = await get_hls_substream(self.mass, ctrp256_urls[0])
+        track_url = playlist_item.path
+        key = playlist_item.key
+        return (track_url, key)
+
+    def _get_decryption_headers(self):
+        """Get headers for decryption requests."""
+        return {
+            "authorization": f"Bearer {DEVELOPER_TOKEN}",
+            "media-user-token": self._music_user_token,
+            "connection": "keep-alive",
+            "accept": "application/json",
+            "origin": "https://music.apple.com",
+            "referer": "https://music.apple.com/",
+            "accept-encoding": "gzip, deflate, br",
+            "content-type": "application/json;charset=utf-8",
+            "user-agent": (
+                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
+                " Chrome/110.0.0.0 Safari/537.36"
+            ),
+        }
+
+    async def _get_decryption_key(
+        self, license_url: str, key_id: str, uri: str, item_id: str
+    ) -> str:
+        """Get the decryption key for a song."""
+        cache_key = f"{self.instance_id}.decryption_key.{key_id}"
+        if decryption_key := await self.mass.cache.get(cache_key):
+            self.logger.debug("Decryption key for %s found in cache.", item_id)
+            return decryption_key
+        pssh = self._get_pssh(key_id)
+        device = Device(
+            client_id=self._decrypt_client_id,
+            private_key=self._decrypt_private_key,
+            type_=DeviceTypes.ANDROID,
+            security_level=3,
+            flags={},
+        )
+        cdm = Cdm.from_device(device)
+        session_id = cdm.open()
+        challenge = cdm.get_license_challenge(session_id, pssh)
+        track_license = await self._get_license(challenge, license_url, uri, item_id)
+        cdm.parse_license(session_id, track_license)
+        key = next(key for key in cdm.get_keys(session_id) if key.type == "CONTENT")
+        if not key:
+            raise MediaNotFoundError("Unable to get decryption key for song %s.", item_id)
+        cdm.close(session_id)
+        decryption_key = key.key.hex()
+        self.mass.create_task(self.mass.cache.set(cache_key, decryption_key, expiration=7200))
+        return decryption_key
+
+    def _get_pssh(self, key_id: bytes) -> PSSH:
+        """Get the PSSH for a song."""
+        pssh_data = WidevinePsshData()
+        pssh_data.algorithm = 1
+        pssh_data.key_ids.append(key_id)
+        init_data = base64.b64encode(pssh_data.SerializeToString()).decode("utf-8")
+        return PSSH.new(system_id=PSSH.SystemId.Widevine, init_data=init_data)
+
+    async def _get_license(self, challenge: bytes, license_url: str, uri: str, item_id: str) -> str:
+        """Get the license for a song based on the challenge."""
+        challenge_b64 = base64.b64encode(challenge).decode("utf-8")
+        data = {
+            "challenge": challenge_b64,
+            "key-system": "com.widevine.alpha",
+            "uri": uri,
+            "adamId": item_id,
+            "isLibrary": False,
+            "user-initiated": True,
+        }
+        async with self.mass.http_session.post(
+            license_url, data=json.dumps(data), headers=self._get_decryption_headers(), ssl=False
+        ) as response:
+            response.raise_for_status()
+            content = await response.json(loads=json_loads)
+            track_license = content.get("license")
+            if not track_license:
+                raise MediaNotFoundError("No license found for song %s.", item_id)
+            return track_license
diff --git a/music_assistant/server/providers/apple_music/bin/README.md b/music_assistant/server/providers/apple_music/bin/README.md
new file mode 100644 (file)
index 0000000..a710ed6
--- /dev/null
@@ -0,0 +1,7 @@
+# Content Decryption Module (CDM)
+You need a custom CDM if you would like to playback Apple music on your local machine. The music provider expects two files to be present in your local user folder `/usr/local/bin/widevine_cdm` :
+
+1. client_id.bin
+2. private_key.pem
+
+These two files allow Music Assistant to decrypt Widevine protected songs. More info on how you can obtain your own CDM files can be found [here](https://www.ismailzai.com/blog/picking-the-widevine-locks).
diff --git a/music_assistant/server/providers/apple_music/icon.svg b/music_assistant/server/providers/apple_music/icon.svg
new file mode 100644 (file)
index 0000000..ef11384
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" aria-label="Apple Music" role="img" viewBox="0 0 512 512" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><rect width="512" height="512" rx="15%" fill="url(#g)"></rect><linearGradient id="g" x1=".5" y1=".99" x2=".5" y2=".02"><stop offset="0" stop-color="#FA233B"></stop><stop offset="1" stop-color="#FB5C74"></stop></linearGradient><path fill="#ffffff" d="M199 359V199q0-9 10-11l138-28q11-2 12 10v122q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69V88s0-20-17-15l-170 35s-13 2-13 18v203q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69"></path></g></svg>
diff --git a/music_assistant/server/providers/apple_music/manifest.json b/music_assistant/server/providers/apple_music/manifest.json
new file mode 100644 (file)
index 0000000..44d212f
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "apple_music",
+  "name": "Apple Music",
+  "description": "Support for the Apple Music streaming provider in Music Assistant.",
+  "codeowners": ["@MarvinSchenkel"],
+  "requirements": ["pywidevine==1.8.0"],
+  "documentation": "https://music-assistant.io/music-providers/apple-music/",
+  "multi_instance": true
+}
index fa264ea025644bf76796448a51b720a306c6c2aa..3d22d0569b50bea320fa0326b957757babface4f 100644 (file)
@@ -30,6 +30,7 @@ PyChromecast==14.0.1
 pycryptodome==3.20.0
 python-fullykiosk==0.0.12
 python-slugify==8.0.4
+pywidevine==1.8.0
 radios==0.3.1
 shortuuid==1.0.13
 snapcast==2.3.6