Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
44 changes: 40 additions & 4 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
27 changes: 26 additions & 1 deletion streamrip/rip/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions streamrip/rip/parse_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+)"
Expand Down Expand Up @@ -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)