diff --git a/README.md b/README.md index 0df40b69..c69bc2f1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. - Fast, concurrent downloads powered by `aiohttp` - Downloads tracks, albums, playlists, discographies, and labels from Qobuz, Tidal, Deezer, and SoundCloud +- Downloads Deezer liked tracks from a user profile (`/profile/USER_ID/loved`) - Supports downloads of Spotify and Apple Music playlists through [last.fm](https://www.last.fm) - Automatically converts files to a preferred format - Has a database that stores the downloaded tracks' IDs so that repeats are avoided @@ -124,6 +125,12 @@ Download a last.fm playlist using the lastfm command rip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 ``` +Download your Deezer liked tracks + +```bash +rip url https://www.deezer.com/fr/profile/USER_ID/loved +``` + For more customization, see the config file ``` diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..25f9cad4 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -98,14 +98,50 @@ async def get_album(self, item_id: str) -> dict: return album_metadata async def get_playlist(self, item_id: str) -> dict: - pl_metadata, pl_tracks = await asyncio.gather( - asyncio.to_thread(self.client.api.get_playlist, item_id), - asyncio.to_thread(self.client.api.get_playlist_tracks, item_id), - ) + if item_id.startswith("favorites:"): + user_id = item_id[len("favorites:"):] + return await self.get_user_favorites(user_id) + + try: + pl_metadata, pl_tracks = await asyncio.gather( + asyncio.to_thread(self.client.api.get_playlist, item_id), + asyncio.to_thread(self.client.api.get_playlist_tracks, item_id), + ) + except DataException: + new_id = await self._resolve_redirect("playlist", item_id) + if new_id: + return await self.get_playlist(new_id) + raise + pl_metadata["tracks"] = pl_tracks["data"] pl_metadata["track_total"] = len(pl_tracks["data"]) return pl_metadata + async def get_user_favorites(self, user_id: str) -> dict: + """ + Fetches the loved tracks for a Deezer user profile. + + Args: + user_id (str): The Deezer user ID. + + Returns: + dict: A playlist-shaped dict with title and tracks list. + """ + # deezer-py's get_user_tracks() drops the limit arg when routing to + # get_my_favorite_tracks(), so we detect own profile and call it directly. + logged_in_id = (await asyncio.to_thread(self.client.gw.get_user_data))["USER"]["USER_ID"] + if int(user_id) == logged_in_id: + tracks = await asyncio.to_thread(self.client.gw.get_my_favorite_tracks, 2000) + else: + tracks = await asyncio.to_thread( + self.client.gw.get_user_tracks, int(user_id), 2000 + ) + return { + "title": "Loved Tracks", + "tracks": tracks, + "track_total": len(tracks), + } + async def get_artist(self, item_id: str) -> dict: artist, albums = await asyncio.gather( asyncio.to_thread(self.client.api.get_artist, item_id), diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2b3f532e..f41a9156 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -169,7 +169,32 @@ def rip( @click.pass_context @coro async def url(ctx, urls): - """Download content from URLs.""" + """Download content from URLs. + + Supported URL formats: + + \b + Deezer: + https://www.deezer.com/fr/track/123456 + https://www.deezer.com/fr/album/123456 + https://www.deezer.com/fr/playlist/123456 + https://www.deezer.com/fr/artist/123456 + https://www.deezer.com/fr/profile/USER_ID/loved (liked tracks) + + \b + Qobuz: + https://www.qobuz.com/us-en/album/... + https://www.qobuz.com/us-en/interpreter/... + + \b + Tidal: + https://tidal.com/browse/album/... + https://tidal.com/browse/track/... + + \b + SoundCloud: + https://soundcloud.com/artist/track + """ if ctx.obj["config"] is None: return diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index bdfa0b03..c4707ea0 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -134,6 +134,28 @@ async def extract_interpreter_url(url: str, client: Client) -> str: ) +class DeezerFavoriteURL(URL): + favorite_re = re.compile( + r"https://(?:www\.)?deezer\.com/[a-z]{2}/profile/(\d+)/loved" + ) + + @classmethod + def from_str(cls, url: str) -> URL | None: + match = cls.favorite_re.match(url) + if match is None: + return None + return cls(match, "deezer") + + async def into_pending( + self, + client: Client, + config: Config, + db: Database, + ) -> Pending: + user_id = self.match.group(1) + return PendingPlaylist(f"favorites:{user_id}", client, config, db) + + class DeezerDynamicURL(URL): standard_link_re = re.compile( r"https://www\.deezer\.com/[a-z]{2}/(album|artist|playlist|track)/(\d+)" @@ -232,6 +254,7 @@ def parse_url(url: str) -> URL | None: QobuzInterpreterURL.from_str(url), SoundcloudURL.from_str(url), DeezerDynamicURL.from_str(url), + DeezerFavoriteURL.from_str(url), # TODO: the rest of the url types ] return next((u for u in parsed_urls if u is not None), None)