"""Deezer API client. Thin async wrapper over the Deezer public REST API. Mirrors Sony.Filtr.DeezerAPI.DeezerApi from the .NET codebase. All methods accept an access_token (per-service-account OAuth token stored in tblServiceAccount.AccessToken). ## Deezer API quirks (learned from integration) ### Parameter passing conventions — NOT uniform across endpoints - GET /playlist/{id} → access_token as query param ✓ - POST /playlist/{id}/tracks (add) → access_token + songs in form body ✓ - DELETE /playlist/{id}/tracks → access_token + songs as QUERY PARAMS (no body) - POST /playlist/{id}/tracks (order) → access_token in body, `order`=comma-sep IDs - POST /playlist/{id} (edit metadata) → access_token as QUERY PARAM, rest in form body ### Ordering tracks Use POST /playlist/{id}/tracks with `order=id1,id2,id3` (comma-separated IDs in desired position). Do NOT include a `songs` key — that triggers add-track logic and returns "This song already exists in this playlist". ### Boolean parameters `public` and `collaborative` must be the strings `"true"` / `"false"`. Integers 0/1 cause ParameterException: "Public parameter must be true or false". ### Error responses Deezer often returns HTTP 200 with a JSON error body rather than a 4xx/5xx: {"error": {"type": "ParameterException", "message": "...", "code": 500}} Or simply boolean `false` for permission/validation failures. `_post()` and `_delete()` both check for these and raise httpx.HTTPStatusError. ### Pagination GET /playlist/{id} returns only 25 tracks by default. Follow the `next` URL in the response until it is absent to retrieve all tracks. This is handled automatically by `get_playlist()`. """ import logging from collections.abc import Iterator from typing import Any, Optional import httpx logger = logging.getLogger("playlist_sync.deezer_client") _DEEZER_BASE = "https://api.deezer.com" _CHUNK_SIZE = 100 def _chunk(lst: list, size: int) -> Iterator[list]: for i in range(0, len(lst), size): yield lst[i : i + size] async def _get(path: str, params: Optional[dict] = None) -> Any: async with httpx.AsyncClient() as client: resp = await client.get(f"{_DEEZER_BASE}{path}", params=params, timeout=30) resp.raise_for_status() return resp.json() async def _get_url(url: str, params: Optional[dict] = None) -> Any: """Fetch an absolute URL (used for Deezer pagination `next` links).""" async with httpx.AsyncClient() as client: resp = await client.get(url, params=params, timeout=30) resp.raise_for_status() return resp.json() async def _post(path: str, data: dict, params: Optional[dict] = None) -> Any: async with httpx.AsyncClient() as client: resp = await client.post( f"{_DEEZER_BASE}{path}", data=data, params=params, timeout=30 ) resp.raise_for_status() result = resp.json() # Deezer sometimes returns HTTP 200 with a JSON error body or boolean false # instead of a proper HTTP error status. if isinstance(result, dict) and "error" in result: raise httpx.HTTPStatusError( f"Deezer API error: {result['error']}", request=resp.request, response=resp, ) if result is False: raise httpx.HTTPStatusError( "Deezer API returned false (permission or validation error)", request=resp.request, response=resp, ) return result async def _delete(path: str, params: dict) -> Any: # Deezer DELETE endpoints read their parameters from the query string, not the body. async with httpx.AsyncClient() as client: resp = await client.request( "DELETE", f"{_DEEZER_BASE}{path}", params=params, timeout=30 ) resp.raise_for_status() result = resp.json() if isinstance(result, dict) and "error" in result: raise httpx.HTTPStatusError( f"Deezer API error: {result['error']}", request=resp.request, response=resp, ) if result is False: raise httpx.HTTPStatusError( "Deezer API returned false (permission or validation error)", request=resp.request, response=resp, ) return result async def get_playlist(playlist_id: int, access_token: str) -> Optional[dict]: """Fetch a Deezer playlist with ALL its tracks (follows pagination). Mirrors: DeezerApi.GetPlaylistAsync() """ try: data: dict = await _get( f"/playlist/{playlist_id}", {"access_token": access_token} ) if not data.get("id"): return None # Deezer paginates tracks (default 25 per page); follow `next` links to get all. all_tracks = list(data.get("tracks", {}).get("data", [])) next_url = data.get("tracks", {}).get("next") while next_url: page = await _get_url(next_url, {"access_token": access_token}) all_tracks.extend(page.get("data", [])) next_url = page.get("next") data.setdefault("tracks", {})["data"] = all_tracks return data except httpx.HTTPStatusError as exc: logger.warning("Failed to fetch Deezer playlist %s: %s", playlist_id, exc) return None async def get_track_by_isrc(access_token: str, isrc: str) -> Optional[dict]: """Look up a Deezer track by ISRC. Mirrors: DeezerApi.GetTrackByISRCAsync() """ try: data = await _get(f"/2.0/track/isrc:{isrc}", {"access_token": access_token}) return data if data.get("id") else None except httpx.HTTPStatusError as exc: logger.debug("ISRC lookup failed for %s: %s", isrc, exc) return None async def delete_playlist_tracks( access_token: str, playlist_id: int, track_ids: list[int], ) -> None: """Remove tracks from a Deezer playlist (batched in 100s). Mirrors: DeezerApi.DeletePlaylistTracksAsync() """ for batch in _chunk(track_ids, _CHUNK_SIZE): songs = ",".join(str(t) for t in batch) try: await _delete( f"/playlist/{playlist_id}/tracks", {"access_token": access_token, "songs": songs}, ) except httpx.HTTPStatusError as exc: logger.error( "Failed to delete tracks from Deezer playlist %s: %s", playlist_id, exc ) raise async def add_tracks( access_token: str, playlist_id: int, track_ids: list[int], ) -> None: """Add tracks to a Deezer playlist (batched in 100s). Mirrors: DeezerApi.AddTracksAsync() """ for batch in _chunk(track_ids, _CHUNK_SIZE): songs = ",".join(str(t) for t in batch) try: await _post( f"/playlist/{playlist_id}/tracks", {"access_token": access_token, "songs": songs}, ) except httpx.HTTPStatusError as exc: logger.error( "Failed to add tracks to Deezer playlist %s: %s", playlist_id, exc ) raise async def order_playlist_tracks( access_token: str, playlist_id: int, track_ids: list[int], ) -> None: """Set the track order for a Deezer playlist. Per Deezer API docs: POST /playlist/{id}/tracks with `order` = comma-separated track IDs in the desired order. Do NOT include `songs` — that triggers add logic. Mirrors: DeezerApi.OrderPlaylistTracksAsync() """ order = ",".join(str(t) for t in track_ids) try: await _post( f"/playlist/{playlist_id}/tracks", {"access_token": access_token, "order": order}, ) except httpx.HTTPStatusError as exc: logger.error( "Failed to order tracks in Deezer playlist %s: %s", playlist_id, exc ) raise async def set_playlist_info( access_token: str, playlist_id: int, title: str, description: str, public: bool, collaborative: bool, ) -> None: """Update Deezer playlist metadata. Per Deezer API docs, access_token must be a query parameter for POST /playlist/{id}. Title, description, public, collaborative go in the form body. Mirrors: DeezerApi.SetPlaylistInfoAsync() """ try: await _post( f"/playlist/{playlist_id}", { "title": title, "description": description, "public": "true" if public else "false", "collaborative": "true" if collaborative else "false", }, params={"access_token": access_token}, ) except httpx.HTTPStatusError as exc: logger.error("Failed to update Deezer playlist info %s: %s", playlist_id, exc) raise