From da8dd2c16a600b6a28392ed6c8cbc0f80b48cc20 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Thu, 25 Jun 2026 21:27:43 +0200 Subject: [PATCH] fix(deezer): paginate song.getFavoriteIds to fetch all loved tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem song.getFavoriteIds silently caps responses at roughly 25 entries per call regardless of the nb= parameter. The previous implementation called get_user_tracks() / get_my_favorite_tracks() which suffer the same cap (default limit=25 in deezer-py). Users with more than ~25 favorites only had a fraction of their library downloaded. A secondary issue: comparing the URL user_id against logged_in_user_id to detect "own profile" is unreliable for family accounts. change_account() shifts current_user to a child profile whose id differs from the main account's USER_ID that authenticated the ARL, so the comparison always fell into the "other user" branch and called get_user_tracks() without the full limit. ## Fix - Paginate song.getFavoriteIds via start= until the server returns an empty page. advance start by the actual count returned (not the requested page size) so the loop handles any server page size. - Remove the uid comparison entirely: song.getFavoriteIds carries no user_id parameter and always returns the authenticated account's favorites, making it the correct call regardless of family setup. - Cache logged_in_user_id during login() from get_user_data() for future use without additional round-trips. - Add DeezerFavoriteURL to parse_url.py to support profile liked-track URLs: https://www.deezer.com/{locale}/profile/{user_id}/loved ## Verified with - User with 406 favorites: 6x getFavoriteIds calls (5 data pages of ~100 entries + 1 empty terminator), all 406 tracks resolved. - Family account (child profile id ≠ main account USER_ID): favorites fetched correctly without falling back to the wrong API method. Co-Authored-By: Claude Sonnet 4.6 --- streamrip/client/deezer.py | 51 ++++++++++++++++++++++++++++++++++++++ streamrip/rip/parse_url.py | 28 +++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..8b26da30 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -28,17 +28,21 @@ class DeezerClient(Client): logged_in: True if logged in config: deezer local config session: aiohttp.ClientSession, used only for track downloads not API requests + logged_in_user_id: USER_ID of the authenticated account, set during login + max_favorites: upper bound for favorites pagination """ source = "deezer" max_quality = 2 + max_favorites = 10_000 def __init__(self, config: Config): self.global_config = config self.client = deezer.Deezer() self.logged_in = False self.config = config.session.deezer + self.logged_in_user_id: int | None = None async def login(self): # Used for track downloads @@ -52,6 +56,8 @@ async def login(self): if not success: raise AuthenticationError self.logged_in = True + user_data = await asyncio.to_thread(self.client.gw.get_user_data) + self.logged_in_user_id = user_data.get("USER", {}).get("USER_ID") async def get_metadata(self, item_id: str, media_type: str) -> dict: # TODO: open asyncio PR to deezer py and integrate @@ -98,6 +104,9 @@ async def get_album(self, item_id: str) -> dict: return album_metadata async def get_playlist(self, item_id: str) -> dict: + if item_id.startswith("favorites:"): + user_id = item_id.removeprefix("favorites:") + return await self.get_user_favorites(user_id) 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), @@ -106,6 +115,48 @@ async def get_playlist(self, item_id: str) -> dict: pl_metadata["track_total"] = len(pl_tracks["data"]) return pl_metadata + async def get_user_favorites(self, user_id: str) -> dict: + """Fetch the loved tracks for the authenticated Deezer account. + + ``song.getFavoriteIds`` silently caps responses at roughly 25 entries per + call regardless of the ``nb`` parameter; the ``start`` parameter must be + advanced by the actual count returned to paginate through all favorites. + + ``song.getFavoriteIds`` carries no ``user_id`` parameter — it always + returns the authenticated user's favorites. Comparing ``user_id`` against + ``logged_in_user_id`` to detect "other user" is unreliable for family + accounts: ``change_account()`` shifts ``current_user`` to a child profile + whose id differs from the main account's USER_ID that authenticated the + ARL. The ``user_id`` argument is accepted for URL-routing compatibility + but is not forwarded to the GW call. + + Args: + user_id: The Deezer user ID from the profile URL. Accepted for + routing compatibility; the GW call always uses the authenticated + account. + + Returns: + Playlist-shaped dict with "title", "tracks", and "track_total". + """ + page_size = 100 + all_entries: list[dict] = [] + start = 0 + while len(all_entries) < self.max_favorites: + response = await asyncio.to_thread( + self.client.gw.get_user_favorite_ids, limit=page_size, start=start + ) + entries: list[dict] = response.get("data", []) + if not entries: + break + all_entries.extend(entries) + start += len(entries) + + return { + "title": "Loved Tracks", + "tracks": [{"id": str(entry["SNG_ID"])} for entry in all_entries], + "track_total": len(all_entries), + } + 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/parse_url.py b/streamrip/rip/parse_url.py index bdfa0b03..837704cf 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -187,6 +187,33 @@ async def _extract_info_from_dynamic_link( raise Exception("Unable to extract Deezer dynamic link.") +class DeezerFavoriteURL(URL): + """Matches Deezer liked-tracks profile URLs. + + Example: https://www.deezer.com/fr/profile/1234567/loved + """ + + 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 SoundcloudURL(URL): source = "soundcloud" @@ -232,6 +259,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)