Skip to content

Rewrite Deezer provider with GraphQL client#3900

Open
jdaberkow wants to merge 86 commits into
devfrom
feature/deezer-gql-replacement
Open

Rewrite Deezer provider with GraphQL client#3900
jdaberkow wants to merge 86 commits into
devfrom
feature/deezer-gql-replacement

Conversation

@jdaberkow
Copy link
Copy Markdown
Member

@jdaberkow jdaberkow commented May 17, 2026

Summary

Full rewrite of the Deezer provider, replacing the REST-based deezer-python dependency with deezer-python-gql - a typed async GraphQL client for Deezer's Pipe API.

Core architecture

  • All metadata (tracks, albums, artists, playlists, podcasts, audiobooks) now fetched via typed GraphQL queries with Pydantic response models
  • Shared GraphQL fragments provide consistent field coverage across all item types
  • Cursor-based pagination for nested collections (album tracks, playlist tracks, artist albums, audiobook chapters)
  • Shares MA's aiohttp.ClientSession (self.mass.http_session) for connection pooling with JWT auto-refresh

New capabilities

  • Simplified auth: Only the ARL cookie token is needed - removed the OAuth browser flow that previously required both an access token and ARL
  • Podcasts: full library sync, episode browsing, bookmark/resume state sync (read + write via on_played)
  • Audiobooks: library sync, chapter navigation with cumulative position calculation
  • Livestreams (radio): search and playback via external stream URLs
  • Lyrics: synchronized (LRC) and plain text from GraphQL
  • Similar artists: discover related artists via ProviderFeature.SIMILAR_ARTISTS
  • Music Together (Shaker): group discovery, suggested and curated playlists in browse/recommendations
  • Flow variants: mood and genre Flows discovered dynamically via flow config queries
  • Smart Tracklists: "Made for Me" mixes surfaced in browse and recommendations
  • Dynamic playlists (is_dynamic): Flow, FlowConfig, and Shaker playlists return fresh tracks on each playback - enables endless radio-style queue refill
  • Share URL parsing: Deezer URLs (deezer.com/{type}/{id}) are now resolved from search/paste, with Deezer added to PROVIDERS_WITH_SHAREABLE_URLS
  • Personal songs: user-uploaded tracks accessible via "My Uploads" virtual playlist (GW API personal_song.getList)
  • Browse folders: Made For You, Explore (charts, new releases, editorial playlists), Recently Played (including SmartTracklist items), Shaker, Discover Audiobooks

Dependency change

  • Removed: deezer-python (REST, unmaintained)
  • Added: deezer-python-gql (async GraphQL, Pydantic models, auto-generated typed client, aiohttp backend)

GW client changes

  • Retained for: track streaming (URL + Blowfish decryption), listen logging, audiobook channel browsing, country code, personal songs
  • Non-streaming REST calls removed
  • Streaming size calculation made robust for personal tracks (FILESIZE_MP3_MISC fallback)

Breaking changes

None. Drop-in replacement. Existing ARL token configuration is unchanged.

Related

Replace the REST-based deezer-python-async client with the typed
GraphQL client (deezer-python-gql) for all Deezer API operations
except streaming and listen logging (which remain on the GW API).

Key changes:
- Auth: remove OAuth flow, use ARL-only authentication via GQL JWT
- Library sync: cursor-paginated favorites via GQL (artists, albums,
  tracks, playlists) with favoritedAt → date_added mapping
- Search: single GQL call replaces 4 parallel REST calls
- Get-by-ID: all entity lookups via GQL with richer metadata
- Favorites: add/remove via GQL mutations
- Playlist CRUD: create, add/remove tracks via GQL mutations
- Recommendations: Flow, mood/genre FlowConfigs, SmartTracklists,
  charts, hot tracks, new releases — replaces curated radios
- Lyrics: add ProviderFeature.LYRICS with plain text and LRC sync
- GW client: remove unused api_token parameter
- Manifest: deezer-python-async → deezer-python-gql>=0.3.0
- Override browse() with 3 custom folders alongside standard library paths
- Made For You: Moods, Genres, Your Top Tracks/Artists/Albums, SmartTracklists, Hot Tracks
- Explore: Top Charts, Top Albums/Artists/Playlists, All Flows
- Recently Played: mixed albums/playlists/artists
- Add Recently Played to recommendations()
- Replace Charts & Hot Tracks recommendation with directly rendered Recommended Tracks
- Filter Recommendations folder from browse root (covered by custom folders)
- Wire up GENRE_FLOW_PREFIX, FLOW_CONFIG_PREFIX, HOT_TRACKS, USER_TOP_TRACKS virtual playlists
- Set favorite flag from isFavorite on tracks, artists, albums, playlists
- Auto-favorite own playlists (owner matches current user)
- Add popularity from track.popularity and fansCount on artists/albums/playlists
- Add label and copyright on albums
- Add bio summary as description on artists
- Add description on playlists
- Extract flow config images for detail pages (visuals)
- Fetch 4x flow track batches with deduplication
- Handle Flow and FlowConfig nodes in recently played
- Upgrade to deezer-python-gql 0.5.0
- Replace Any with TrackFields, AlbumFields, ArtistFields, PlaylistFields
- Remove 20 defensive getattr/hasattr calls in favor of direct field access
- Bump deezer-python-gql requirement to >=0.5.1 (fragment inheritance)
- Extract _parse_recently_played_edges to deduplicate browse + recs
- DRY up _get_virtual_playlist: consolidate 3 identical flow config branches
- Type _get_flow_config_image with Protocol instead of object+getattr
- Deduplicate _get_hot_tracks (delegates to _get_recommended_tracks)
- Remove dead GW methods: get_user_radio, get_home_flows, get_song_data
- Add get_similar_track_ids public method on GWClient
- Add httpx connection pooling (AsyncClient lifecycle in init/unload)
- Fix _add_flow_configs to use _get_flow_config_image consistently
Replace with direct _get_recommended_tracks call — same API, clearer name.
- Merge _browse_mood_flows/_browse_genre_flows into _browse_flow_configs(category)
- Consolidate 3 get_playlist_tracks prefix dispatches into loop
- Remove HOT_TRACKS_PLAYLIST_ID (identical to RECOMMENDED_TRACKS_PLAYLIST_ID)
- Single get_recommendations() API call instead of 3 separate calls
- Shared cached _get_recently_played_items() for browse + recommendations
- Shared cached _get_smart_tracklist_playlists() for browse + recommendations
- Fix bug: _add_flow_configs used MOOD_FLOW_PREFIX for genres (now GENRE_FLOW_PREFIX)
…gle FLOW_CONFIG_PREFIX

FlowConfig is Deezer's single API type for all personalized radio streams
(moods, genres, and search results). The 3 separate prefixes were unnecessary
since every downstream operation (track fetching, virtual playlist creation)
is identical regardless of how the FlowConfig was discovered.
- _iter_favorites(): generic cursor-paginated favorites loop (artists, albums, tracks)
- _flow_configs_to_playlists(): shared FlowConfig edge→playlist conversion
- _apply_web_url(): extracted duplicate URL-patching from get_artist/get_album
- Removed trivial _browse_recently_played wrapper (inlined at call site)
…p deezer-python-gql to 0.7.0

- Add audiobook browsing via GW page.get API (Discover Audiobooks with genre channels)
- Split audiobook results from album search using check_audiobook_ids
- Cache podcast episodes with @use_cache (3h TTL), apply fresh bookmarks on top
- Fix all browse breadcrumb paths to use human-readable display names
- Add Shaker (artist/track mix) browse support
- Bump deezer-python-gql requirement from >=0.5.1 to >=0.7.0
- Remove httpx import and manual AsyncClient management
- DeezerGQLClient now manages its own connection pool internally
- Podcast episodes cached individually by ID for 30 days
- Only uncached episodes are fetched from the API on sync
- Fix edge variable reuse across elif branches (charts, user charts)
- Fix flow configs tuple type inference with explicit list() cast
- Replace Protocol with concrete Union types for _get_flow_config_image
- Use distinct variable names for flow_config/tracklist in _get_virtual_playlist
- Remove unreachable statements (non-optional contributor edges, exhaustive match)
- Fix similar tracks attribute access (ShorterResultsPlugin unwrap)
- Fix search album edges: extract .node for proper Optional narrowing
- Fix podcast episode variable shadowing across loops
- Replace Sequence[Any] with concrete generated types
- Update manifest pin to deezer-python-gql==0.8.3
- Paginate nested collections (album tracks, playlist tracks, artist
  albums/top tracks, audiobook chapters) instead of truncating at first page
- Paginate podcast bookmark retrieval and reset stale state before overlay
- Implement on_played() to sync podcast progress back to Deezer
- Fix empty radio generator to prevent AttributeError during sync
- Invalidate playlist track cache after add/remove mutations
- Bump deezer-python-gql to 0.9.0 (pagination query variables)
- Rewrite PR description to match actual branch scope
get_library_audiobooks() now calls the provider's paginated
get_audiobook() method instead of bypassing it with a direct
GQL client call limited to 200 chapters.
Deezer livestreams have no favorites API, so get_library_radios() was an
empty generator. With the flag declared, radio sync would run, return no
items, and mark previously-synced radios in_library=False — silently
clearing favorites on subsequent syncs.

Radios remain discoverable via search and playable. MA-local favorites
still work without the flag.
- Add Personalized Playlists, Recommended Playlists, and Recommended
  Artist Playlists folders under Made For You in browse()
- Split editorial and artist playlists into own recommendation sections
  (50 items each) replacing the old plain Recommended Artists section
- Remove editorial playlists from Made for you recommendation folder
- Extract _browse_made_for_you() dispatcher to stay within return limit
- Parse personal songs (negative SNG_ID) from GW API with full
  Artist/Album objects using prefixed IDs to avoid collisions
- Yield personal artists/albums in get_library_artists/albums so
  they appear in library overview and search
- Handle personal_artist_/personal_album_ prefixes in get_artist,
  get_album, get_artist_albums, get_artist_toptracks, get_album_tracks
  to avoid sending invalid IDs to the GQL API
- Mark personal items as favorites so they appear in library
…bums

Return Album instead of ItemMapping for track.album in parse_track so
the core tracks controller can display 'Appears on' albums without
needing to resolve ItemMappings (which it currently doesn't do).
- Populate album artists from track MAIN contributors in parse_track
- Filter audiobooks from library albums using check_audiobook_ids
- Yield audiobooks from favorite albums in get_library_audiobooks
- Add audiobook chapter streaming with seek support
- Use album favorite mutations for audiobooks (deprecated endpoints fail)
jdaberkow and others added 15 commits May 24, 2026 22:45
Deduplicate identical bookmark fetching logic that existed in both
media.py (_fetch_all_bookmarks) and streaming.py (get_resume_position).

The new fetch_all_bookmarks() helper in helpers.py is now used by both
callers, reducing code duplication and ensuring consistent behavior.
Addresses reviewer concern about double caching. The two-layer strategy
is intentional (outer=navigation speed, inner=avoid refetching details)
but 24h was too aggressive. Reduced to 1h so new episodes appear faster.
Added docstring explaining the caching design.
Replace 6 redundant get_personal_songs API calls with a single cached
_get_personal_songs() method (24h TTL). Also fixes a pre-existing bug
where only the first 500 songs were fetched — now paginates until
exhausted so users with 500+ uploads get all their songs.
…:music-assistant/server into feature/deezer-gql-replacement
…ters

- Add _CoverLike Protocol for _cover_image parameter (Issue #2)
- Add _HasUrl Protocol for apply_web_url parameter (Issue #7)
- Remove redundant hasattr guards now covered by type contracts
Define BROWSE_* constants in helpers.py for all path segments used in
browse routing. Replaces hardcoded string literals in browse.py with
constants to prevent silent routing breakage from typos.

Also fixes inconsistent casing: 'Made for you' → 'Made For You'.
- Reject non-numeric entity IDs when parsing Deezer share URLs
- Add defensive ValueError handling in get_track for invalid IDs
- Delegate browse personal songs to media_manager's paginated method
  to remove 500-item cap
…:music-assistant/server into feature/deezer-gql-replacement
- Bump deezer-python-gql to 0.17.0 (adds media.rights.sub.available)
- Add _track_available() to detect unplayable/non-subscriber tracks
- Set ProviderMapping.available=False for unavailable tracks
- Extract year from track.release_date for inline Album objects
- Accept GetTrackTrack in parse_track type union
Copy link
Copy Markdown
Contributor

@OzGav OzGav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a couple of other small items to consider. Otherwise LGTM. One of the other guys will have a look at this and we have the models PR question to go. Thanks for all your work on this re-write!

Comment thread music_assistant/providers/deezer/helpers.py Outdated
Comment thread music_assistant/providers/deezer/media.py Outdated
Comment thread music_assistant/providers/deezer/parsers.py Outdated
jdaberkow and others added 2 commits May 26, 2026 22:36
- Move constants to dedicated constants.py module
- Remove hasattr check for raw_episodes (type is known)
- Remove internal-workings docstring lines per MA convention
@jdaberkow jdaberkow force-pushed the feature/deezer-gql-replacement branch from fc331d0 to 455c37d Compare May 26, 2026 21:02
@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented May 26, 2026

Thanks. If you are happy with everything then press the ready for review button and one of the others will have a look soon.

@jdaberkow
Copy link
Copy Markdown
Member Author

Thanks. If you are happy with everything then press the ready for review button and one of the others will have a look soon.

Thank you very much for your detailed reviews! Alright. From my side everything is good to go.

@jdaberkow jdaberkow marked this pull request as ready for review May 28, 2026 20:29
jdaberkow and others added 4 commits May 29, 2026 10:17
…n deserialization

The RecommendationFolder.items field now has a custom deserializer
(music-assistant/models#239) that discriminates Union types by media_type,
so full Playlist/Artist/Album objects survive the to_dict()/from_dict()
roundtrip correctly. The ItemMapping conversion workaround is no longer needed.

Resolves review issue #18.
@jdaberkow jdaberkow requested a review from OzGav May 30, 2026 20:16
item_id="top_albums",
provider=self.instance_id,
path=f"{base}Top Albums",
name="Top Albums",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could make these constants like you did above?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants