diff --git a/music_assistant/constants.py b/music_assistant/constants.py index eb61d3ea84..f04179a40c 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -246,7 +246,7 @@ def load_genre_mapping() -> list[dict[str, Any]]: "player_queues", ) VERBOSE_LOG_LEVEL: Final[int] = 5 -PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz", "apple_music") +PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz", "apple_music", "deezer") ####### REUSABLE CONFIG ENTRIES ####### diff --git a/music_assistant/helpers/uri.py b/music_assistant/helpers/uri.py index 96bd56fda9..b6c7528105 100644 --- a/music_assistant/helpers/uri.py +++ b/music_assistant/helpers/uri.py @@ -81,6 +81,33 @@ async def parse_uri(uri: str, validate_id: bool = False) -> tuple[MediaType, str raise KeyError else: raise KeyError + elif uri.startswith(("https://www.deezer.com/", "https://deezer.com/")): + # Deezer share URL + # https://www.deezer.com/track/123456 + # https://www.deezer.com/en/track/123456 (with locale) + # https://deezer.com/album/789 + _deezer_type_map = { + "track": MediaType.TRACK, + "album": MediaType.ALBUM, + "artist": MediaType.ARTIST, + "playlist": MediaType.PLAYLIST, + "show": MediaType.PODCAST, + "episode": MediaType.PODCAST_EPISODE, + } + parts = uri.rstrip("/").split("?")[0].split("/") + # Find the type segment by checking against the known map + deezer_type = None + deezer_id = None + for i, part in enumerate(parts): + if part in _deezer_type_map and i + 1 < len(parts): + deezer_type = part + deezer_id = parts[i + 1] + break + if deezer_type is None or not deezer_id or not deezer_id.isdigit(): + raise KeyError + provider_instance_id_or_domain = "deezer" + media_type = _deezer_type_map[deezer_type] + item_id = deezer_id elif uri.startswith(("http://", "https://", "rtsp://", "rtmp://")): # Translate a plain URL to the builtin provider provider_instance_id_or_domain = "builtin" diff --git a/music_assistant/providers/deezer/PR_REVIEW_STATUS.md b/music_assistant/providers/deezer/PR_REVIEW_STATUS.md new file mode 100644 index 0000000000..97f1f90524 --- /dev/null +++ b/music_assistant/providers/deezer/PR_REVIEW_STATUS.md @@ -0,0 +1,386 @@ +# PR #3900 — Review Issue Tracking + +This document tracks the current state of all review comments on +PR #3900 "Rewrite Deezer provider with GraphQL client". + +https://github.com/music-assistant/server/pull/3900 + +**Reviewers:** OzGav (code review), marcelveldt (architectural feedback) + +**Final State (verified 2026-05-30):** 22/22 issues resolved. + +**Resolved via models 1.1.127 (merged 2026-05-29):** +- Issue 18 — Core serialization bug workaround removed. The `_deserialize_recommendation_items` custom deserializer in `music-assistant-models` #239 properly discriminates Union types by `media_type`, fixing the `to_dict()`→`from_dict()` roundtrip corruption. + +**Resolved via dev merge (2026-05-24):** +- Issue 10 (type suppression) — PR #3965 merged the `Protocol`-bounded TypeVar fix into dev. All `# type: ignore[type-var]` comments removed from our branch. + +--- + +## Architectural Changes (marcelveldt) + +### A1 — Switch `deezer-python-gql` from httpx to aiohttp + +**Status:** ✅ Complete +**Rationale:** MA uses a shared `aiohttp.ClientSession` across all 50+ providers. The `deezer-python-gql` library currently uses `httpx` (inherited from ariadne-codegen's default base client). ariadne-codegen explicitly supports swapping the HTTP backend via `base_client_file_path`. The base client is already hand-written — only `execute()` and `get_data()` need modification. + +**Done (in `deezer-python-gql` repo):** +- Rewrote `base_client.py` to accept `aiohttp.ClientSession` instead of `httpx.AsyncClient` +- Replaced `httpx.AsyncClient.post()` → `aiohttp.ClientSession.post()` (context manager pattern) +- Return a lightweight `GQLResponse` dataclass from `execute()` (since aiohttp responses can't escape their context manager) +- Changed `pyproject.toml` dependency from `httpx>=0.27.0` to `aiohttp>=3.9.0` +- Updated tests to mock aiohttp instead of httpx +- Dual-mode: accept external session (MA passes `self.mass.http_session`) or create internal session (standalone usage) +- All 82 tests pass, mypy clean, ruff clean + +**Done (in `music-assistant/server` repo):** +- Pass `self.mass.http_session` to `DeezerGQLClient(arl=..., session=self.mass.http_session)` +- Removed `await self.gql_client.close()` from `unload()` (session lifecycle managed by MA) + +**Pending:** Release new `deezer-python-gql` version to PyPI, update `requirements_all.txt` in server repo + +### A2 — Pydantic models (acknowledged constraint) + +**Status:** Accepted as-is +**Rationale:** ariadne-codegen only generates Pydantic models — no configuration or plugin exists for dataclass/mashumaro output. The Pydantic models are confined to the `deezer-python-gql` library boundary: they're parsed into MA's own dataclass-based `MediaItem` types in `parsers.py` and immediately discarded. They never flow through MA's core. The memory/CPU overhead is negligible compared to network I/O. + +### A3 — Add `artist_str` property to `Audiobook` model + +**Status:** Planned (separate PR to `music-assistant/models` repo) +**Rationale:** `player_queues.py` uses `getattr(media_item, "artist_str", "")` to populate the `PlayerMedia.artist` display field. `Album` and `Track` define this property (joining their `artists` list). `Audiobook` has `authors` and `narrators` but no `artist_str`, so the "now playing" artist line is always empty for audiobooks. This affects all audiobook providers (Deezer, Audiobookshelf, Filesystem Local). + +**Scope (in `music-assistant/models` repo):** +- Add `artist_str` property to `Audiobook` that returns `"/".join(name for a in self.authors)` +- Handles both `str` and `Artist` entries in the `authors` list + +**Note:** This is NOT a Deezer-specific issue and should not be part of PR #3900. Requires cloning `music-assistant/models` repo. + +--- + +## Issue Index (ordered by implementation complexity — largest first) + +| # | File | Complexity | Status | Summary | +| ------ | -------------------- | ----------- | -------- | --------------------------------------------------------------------------- | +| ~~13~~ | ~~media.py:150~~ | ~~High~~ | ~~Done~~ | ~~Album list fetched twice for audiobook detection~~ | +| ~~1~~ | ~~helpers.py:139~~ | ~~High~~ | ~~Done~~ | ~~Double-fetching first audiobook chapter page~~ | +| ~~21~~ | ~~streaming.py:46~~ | ~~High~~ | ~~Done~~ | ~~Duplicated bookmark fetching logic~~ | +| ~~14~~ | ~~media.py:606~~ | ~~Medium~~ | ~~Done~~ | ~~Double caching in `_fetch_podcast_episodes`~~ | +| ~~12~~ | ~~media.py:110~~ | ~~Medium~~ | ~~Done~~ | ~~Personal songs fetched 3 times during library sync~~ | +| 18 | browse.py:595 | Medium | Done | Workaround for core serialization bug (removed) | +| ~~11~~ | ~~media.py:80~~ | ~~Medium~~ | ~~Done~~ | ~~`_iter_paged` loses type safety with `Any`~~ | +| ~~8~~ | ~~provider.py:94~~ | ~~Medium~~ | ~~Done~~ | ~~Unhandled exceptions in `handle_async_init`~~ | +| ~~2~~ | ~~parsers.py:103~~ | ~~Medium~~ | ~~Done~~ | ~~`cover` typed as `object` defeats typing~~ | +| ~~7~~ | ~~parsers.py:840~~ | ~~Medium~~ | ~~Done~~ | ~~`apply_web_url` uses `object` parameter type~~ | +| ~~19~~ | ~~browse.py:101~~ | ~~Medium~~ | ~~Done~~ | ~~String literals should be provider-level constants~~ | +| ~~10~~ | ~~media.py:248~~ | ~~Low~~ | ~~Done~~ | ~~Search cached for 7 days (type suppression resolved via #3965)~~ | +| ~~20~~ | ~~browse.py:946~~ | ~~Low~~ | ~~Done~~ | ~~Caching dynamic content contradicts `is_dynamic=True`~~ | +| ~~4~~ | ~~parsers.py:870~~ | ~~Low~~ | ~~Done~~ | ~~`parse_date` silently falls back to `datetime.now()`~~ | +| ~~6~~ | ~~parsers.py:506~~ | ~~Low~~ | ~~Done~~ | ~~Docstring format doesn't match MA convention~~ | +| ~~5~~ | ~~parsers.py:624~~ | ~~Low~~ | ~~Done~~ | ~~GW parsers assume keys always present~~ | +| ~~22~~ | ~~streaming.py:332~~ | ~~Low~~ | ~~Done~~ | ~~Missing HTTP error handling in audio stream~~ | +| ~~3~~ | ~~parsers.py:334~~ | ~~Trivial~~ | ~~Done~~ | ~~Private attribute `_user_id` accessed from module function~~ | +| ~~15~~ | ~~media.py:711~~ | ~~Trivial~~ | ~~Done~~ | ~~Wrong exception: `NotImplementedError` → `UnsupportedFeaturedException`~~ | +| ~~16~~ | ~~media.py:729~~ | ~~Trivial~~ | ~~Done~~ | ~~Wrong exception: `NotImplementedError` → `UnsupportedFeaturedException`~~ | +| ~~9~~ | ~~provider.py:108~~ | ~~Trivial~~ | ~~Done~~ | ~~No `super().unload()`~~ | +| ~~17~~ | ~~media.py:465~~ | ~~Trivial~~ | ~~Done~~ | ~~Inconsistent `instance_id` access~~ | + +--- + +## Detailed Analysis + +--- + +### Issue 1 — Double-fetching first audiobook chapter page + +**File:** `helpers.py:139` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "`get_audiobook` in `media.py` already fetches with `chapters_first=200` before calling this helper. So the first page is fetched twice." + +**Fix applied:** `fetch_all_audiobook_chapter_edges` now accepts an optional `initial_edges` parameter. `get_audiobook()` passes the already-fetched first page edges, so pagination starts from page 2. No wasted API calls. + +--- + +### Issue 2 — `cover` typed as `object` + +**File:** `parsers.py:103` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "Why is cover typed as object here instead of a proper type that expresses whether .urls is always present or not?" + +**Fix applied:** Defined `_CoverLike` Protocol with a `urls: list[str]` property. Parameter typed as `_CoverLike | None`. No more `hasattr` duck-typing — mypy verifies access statically. + +--- + +### Issue 3 — Private attribute accessed directly + +**File:** `parsers.py:334` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "Why is a private attribute being accessed directly?" + +**Fix applied:** The provider stores the user ID as `self.user_id` (public attribute, set in `handle_async_init`). All access from parsers uses `provider.user_id`. + +--- + +### Issue 4 — Silent fallback to `datetime.now()` on parse failure + +**File:** `parsers.py:870` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "There is a problem with the date but rather than surfacing it you are just overwriting it with now?" + +**Fix applied:** Return type changed to `datetime | None`. Returns `None` on parse failure. Callers handle `None` gracefully (MA treats it as "unknown date"). No more `datetime.now()` in the codebase. + +--- + +### Issue 5 — GW parser key availability assumptions + +**File:** `parsers.py:624` +**Priority:** Nice to have +**Status:** Done +**OzGav comment:** "Are the API responses robust enough that these key values will always be available?" + +**Fix applied:** Entry point `parse_gw_item` validates required keys with `.get()` guards before calling inner parsers, and wraps all inner parser calls in `try/except KeyError` with a debug log. Inner parsers use direct access for keys already validated at entry (e.g., `data["ALB_ID"]` after `data.get("ALB_ID")` guard), and `.get()` with defaults for optional fields. + +--- + +### Issue 6 — Docstring format + +**File:** `parsers.py:506` +**Priority:** Nice to have +**Status:** Done +**OzGav comment:** "Multi line docstrings should have the first line on its own line... Don't explain inner workings." + +**Fix applied:** All multi-line docstrings across the provider follow MA convention: opening `"""` on its own line, concise caller-facing descriptions, no inner implementation details. Sphinx-style `:param:` format used where applicable. + +--- + +### Issue 7 — `apply_web_url` uses `object` parameter type + +**File:** `parsers.py:840` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "The parameter type is object, which defeats typing. Should accept a typed union of the GQL models." + +**Fix applied:** Defined `_HasUrl` Protocol with a `url: object` property. Parameter typed as `_HasUrl` — mypy verifies the attribute exists. Uses `getattr(gql_result.url, "web_url", None)` for the nested access since the inner URL type varies across GQL models. + +--- + +### Issue 8 — Unhandled exceptions in `handle_async_init` + +**File:** `provider.py:94` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "What happens if `get_me()` raises rather than returning None? And what exception does the caller see if `GWClient.setup()` raises `DeezerGWError`?" + +**Fix applied:** Wrapped both client setup calls in a single `try/except (GraphQLClientError, DeezerGWError)` block that re-raises as `LoginFailed`. The `me is None` case raises `GraphQLClientError` internally so there's a single unified `raise LoginFailed(...)` exit point. Follows the Yandex Music provider pattern. Exception chain preserved via `from err`. + +--- + +### Issue 9 — No `super().unload()` + +**File:** `provider.py:108` +**Priority:** Nice to have +**Status:** Done +**OzGav comment:** "No `super().unload()`?" + +**Fix applied:** `await super().unload(is_removed)` is called in the `unload` method. + +--- + +### Issue 10 — Search cached for 7 days + `# type: ignore[type-var]` + +**File:** `media.py:248` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "Why would you cache a search for a week? What is the typing issue with `@use_cache` here and why is it suppressed rather than fixed?" + +**Fix applied:** +- **Cache TTL:** Reduced from 7 days to 15 minutes (`60 * 15`). Fresh enough for discovery, short enough to reflect library changes. +- **Type suppression:** All `# type: ignore[type-var]` comments removed. PR #3965 merged the Protocol-bounded TypeVar fix into dev, which our branch now inherits. + +--- + +### Issue 11 — `_iter_paged` loses type safety + +**File:** `media.py:80` +**Priority:** Nice to have +**Status:** Done +**OzGav comment:** "This is losing type safety. Can this be done better?" + +**Fix applied:** Added Protocol-based structural contracts (`_PageInfo`, `_Connection`) that document and enforce the pagination contract. The `extract` parameter is now typed as `Callable[..., _Connection | None]` instead of `Callable[..., Any]`. mypy validates the method body accesses `.edges` and `.page_info` correctly. Full generic inference at call sites is not possible due to a mypy limitation with Protocol-based TypeVar inference on generated Pydantic models, but the Protocols provide internal correctness and clear documentation of the expected structure. + +--- + +### Issue 12 — Personal songs fetched 3 times + +**File:** `media.py:110` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "Personal songs are fetched separately in `get_library_artists`, `get_library_albums`, and `get_library_tracks`. This should be cached or fetched once." + +**Fix applied:** Added `_get_personal_songs()` method with `@use_cache(3600 * 24)` (24h TTL). All 6 call sites in media.py now use this cached helper. Also fixed a pre-existing bug where only the first 500 songs were fetched — the helper now paginates fully until exhausted. + +--- + +### Issue 13 — Album list fetched twice for audiobook detection + +**File:** `media.py:150` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "`_get_audiobook_ids_in_albums` loops through all favourite albums to collect IDs. Then `get_favorite_albums` loops through the same list again. Full album list fetched twice." + +**Fix applied:** Single-pass in `get_library_albums`: collect all edges into memory, call `check_audiobook_ids` once, yield non-audiobooks from the in-memory list. Renamed `_audiobook_ids_cache` → `_audiobook_ids_in_favorites` for clarity. `_get_audiobook_ids_in_albums()` retained as fallback for standalone `get_library_audiobooks()` calls. + +--- + +### Issue 14 — Double caching in `_fetch_podcast_episodes` + +**File:** `media.py:606` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "This method is cached and now you are also writing to an additional cache?" + +**Fix applied:** Reduced outer `@use_cache` TTL from 24h to 1h and added a docstring explaining the two-layer caching strategy. The outer cache prevents repeated per-episode cache lookups during rapid navigation; the inner 30-day per-episode cache prevents re-fetching episode details. Keeping both layers but with a shorter outer TTL balances freshness (new episodes within 1h) and browsing performance (500-episode podcasts don't trigger 500 cache lookups on every visit). + +--- + +### Issue 15 & 16 — Wrong exception type + +**File:** `media.py:711` and `media.py:729` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "Should be `UnsupportedFeaturedException` from MA errors, not the Python built-in" + +**Fix applied:** Both `library_add` and `library_remove` now raise `UnsupportedFeaturedException(f"Unsupported media type for ...: {media_type}")` with a descriptive message. + +--- + +### Issue 17 — Inconsistent `instance_id` access + +**File:** `media.py:465` +**Priority:** Nice to have +**Status:** Done +**OzGav comment:** "Elsewhere `self.provider.instance_id` is used?" + +**Fix applied:** All manager classes (`DeezerMediaManager`, `DeezerBrowseManager`, `DeezerStreamingManager`) store `self.instance_id = provider.instance_id` in `__init__` and use `self.instance_id` consistently throughout. No mixed access patterns. + +--- + +### Issue 18 — Workaround for core serialization bug + +**File:** `browse.py:595` +**Priority:** Should fix +**OzGav comment:** "I think you will need to fix this rather than work around it." + +**Analysis:** + +The workaround: +```python +# Convert all items to ItemMapping to work around a core serialization bug +for folder in result: + folder.items = UniqueList( + ItemMapping.from_item(item) for item in folder.items + ) +``` + +**Root cause (verified locally):** The bug is specifically in mashumaro's `from_dict()` deserialization of Union-typed fields — NOT in `to_dict()`. Tested with mashumaro 3.20 (MA's pinned version): + +- `to_dict()` works correctly — a `Playlist` in `items` serializes with all its fields (`is_dynamic`, `media_type=playlist`, etc.). The frontend receives correct data. +- `from_dict()` is broken — mashumaro has no discriminator for the Union `MediaItemType | ItemMapping | BrowseFolder`, so it tries types in declaration order. `Artist` is first in `MediaItemType` and its required fields overlap enough that mashumaro picks it for ALL items. A Playlist gets deserialized as `Artist`, losing `is_dynamic` etc. + +**When does `from_dict()` get called?** On `@use_cache` cache hits: `cache.get()` returns raw dicts → `_reconstruct()` calls `parse_value()` → which calls `RecommendationFolder.from_dict()` → mashumaro handles nested `items` field with broken Union resolution. + +**Why the ItemMapping workaround works:** `ItemMapping` dicts fail `Artist.from_dict()` (missing required `provider_mappings` field), so mashumaro falls through until it hits `ItemMapping` — the correct type. Verified locally. + +**Impact across MA:** Apple Music and YTMusic both `@use_cache(3600)` their `recommendations()` with full objects in items. They have the exact same latent bug — on cache hit, items get deserialized as `Artist` regardless of actual type. Likely unnoticed because the frontend receives correct `to_dict()` output on the initial (uncached) call. + +**Note:** MA's `parse_value()` helper DOES correctly handle Unions via `media_type` discrimination (checks `value["media_type"] != value_type.media_type` and falls through). But this only works at the top level — once `RecommendationFolder.from_dict()` is called, mashumaro handles nested fields internally without using `parse_value`. + +**Proper fix options:** +1. **Fix in `music_assistant_models`** — add `Discriminator(field="media_type")` (mashumaro 3.20 supports this via `Annotated` on Union fields or class-level `Config`). Fixes it for all providers. +2. **Keep the ItemMapping workaround** — functionally correct, self-documenting. Items survive the roundtrip and get resolved back to full objects on playback. +3. **Drop `@use_cache` from `recommendations()` here** — sidesteps the deserialization path for Deezer but doesn't help other providers. + +**Decision:** Awaiting reviewer feedback on preferred approach and whether the fix belongs in this PR or a separate one. Question posted on PR. + +--- + +### Issue 19 — String literals should be constants + +**File:** `browse.py:101` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "These should all be provider level constants" + +**Fix applied:** All browse path strings defined as module-level constants in `helpers.py` (`BROWSE_MADE_FOR_YOU`, `BROWSE_EXPLORE`, `BROWSE_RECENTLY_PLAYED`, `BROWSE_SHAKER`, `BROWSE_AUDIOBOOKS`, etc.). Browse routing and folder creation both import and use these constants. + +--- + +### Issue 20 — Caching contradicts `is_dynamic=True` + +**File:** `browse.py:946` +**Priority:** Should fix +**Status:** Done +**OzGav comment:** "Should this be cached since `is_dynamic` is true?" + +**Fix applied:** Removed `@use_cache` from both `_get_flow_tracks` and `_get_flow_config_tracks`. These methods now fetch fresh tracks on every call, consistent with `is_dynamic=True`. `_get_shaker_tracks` was already uncached. + +--- + +### Issue 21 — Duplicated bookmark fetching + +**File:** `streaming.py:46` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "This seems to be duplicating `_fetch_all_bookmarks` in `media.py`" + +**Fix applied:** Extracted pagination logic to `fetch_all_bookmarks(gql_client)` in `helpers.py`. Both `media.py` (removed `_fetch_all_bookmarks` private method) and `streaming.py` (`get_resume_position` now does a dict lookup) use the shared helper. + +--- + +### Issue 22 — Missing HTTP error handling in audio stream + +**File:** `streaming.py:332` +**Priority:** Must fix +**Status:** Done +**OzGav comment:** "You need to add error handling here." + +**Fix applied:** Added `if resp.status != 200: raise MediaNotFoundError(...)` check immediately after opening the HTTP response context manager, before iterating chunks. + +--- + +## Implementation Plan (by complexity — largest first) + +### Phase 0 — Architectural (deezer-python-gql repo) ✅ +- ~~Rewrite `base_client.py` to use aiohttp instead of httpx~~ — Done +- ~~Update tests, bump version, release~~ — Done + +### Phase 1 — High complexity (refactoring across multiple functions) ✅ +1. ~~**Issue 13:** Single-pass album/audiobook detection~~ — Done +2. ~~**Issue 1:** Eliminate double audiobook chapter fetch~~ — Done +3. ~~**Issue 21:** Extract shared bookmark fetching~~ — Done + +### Phase 2 — Medium complexity (localized but multi-line changes) ✅ +4. ~~**Issue 14:** Simplify podcast episode caching~~ — Done +5. ~~**Issue 12:** Cache personal songs~~ — Done +6. **Issue 18:** Document serialization workaround — Awaiting reviewer feedback +7. ~~**Issue 11:** Improve `_iter_paged` typing with Protocol~~ — Done +8. ~~**Issue 8:** Wrap `handle_async_init` in try/except~~ — Done +9. ~~**Issue 2 & 7:** Define Protocol classes for cover/url objects~~ — Done +10. ~~**Issue 19:** Extract browse path strings to constants~~ — Done + +### Phase 3 — Low complexity (single-location changes) ✅ +11. ~~**Issue 10:** Reduce search cache TTL to 15 min, remove type:ignore~~ — Done +12. ~~**Issue 20:** Remove @use_cache from flow track methods~~ — Done +13. ~~**Issue 4:** Change `parse_date` return type to `None`~~ — Done +14. ~~**Issue 6:** Audit and fix docstring format~~ — Done +15. ~~**Issue 5:** Add try/except KeyError in GW parsers~~ — Done +16. ~~**Issue 22:** Add resp.status check in streaming~~ — Done + +### Phase 4 — Trivial (one-line fixes) ✅ +17. ~~**Issue 3:** Use `provider.user_id` property~~ — Done +18. ~~**Issue 15 & 16:** Replace NotImplementedError → UnsupportedFeaturedException~~ — Done +19. ~~**Issue 9:** Add `await super().unload(is_removed)`~~ — Done +20. ~~**Issue 17:** Use `self.instance_id` consistently~~ — Done diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 9566b56b4d..03984a73ce 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -1,145 +1,22 @@ """Deezer music provider support for MusicAssistant.""" -import hashlib -import uuid -from asyncio import TaskGroup -from collections.abc import AsyncGenerator -from dataclasses import dataclass -from datetime import UTC, datetime -from math import ceil -from typing import Any, Literal, cast +from __future__ import annotations -import deezer -from aiohttp import ClientSession, ClientTimeout -from Crypto.Cipher import Blowfish -from deezer import exceptions as deezer_exceptions -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig -from music_assistant_models.enums import ( - AlbumType, - ConfigEntryType, - ContentType, - ExternalID, - ImageType, - MediaType, - ProviderFeature, - StreamType, -) -from music_assistant_models.errors import InvalidDataError, LoginFailed, MediaNotFoundError -from music_assistant_models.media_items import ( - Album, - Artist, - AudioFormat, - ItemMapping, - MediaItemImage, - MediaItemMetadata, - MediaItemType, - Playlist, - ProviderMapping, - RecommendationFolder, - SearchResults, - Track, - UniqueList, -) -from music_assistant_models.provider import ProviderManifest -from music_assistant_models.streamdetails import StreamDetails +from typing import TYPE_CHECKING -from music_assistant import MusicAssistant -from music_assistant.controllers.cache import use_cache -from music_assistant.helpers.app_vars import app_var # type: ignore[attr-defined] -from music_assistant.helpers.auth import AuthenticationHelper -from music_assistant.helpers.datetime import utc_timestamp -from music_assistant.helpers.util import infer_album_type, parse_title_and_version -from music_assistant.models import ProviderInstanceType -from music_assistant.models.music_provider import MusicProvider +from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from music_assistant_models.enums import ConfigEntryType -from .gw_client import GWClient +from .provider import CONF_ARL_TOKEN, SUPPORTED_FEATURES, DeezerProvider -SUPPORTED_FEATURES = { - ProviderFeature.LIBRARY_ARTISTS, - ProviderFeature.LIBRARY_ALBUMS, - ProviderFeature.LIBRARY_TRACKS, - ProviderFeature.LIBRARY_PLAYLISTS, - ProviderFeature.LIBRARY_ALBUMS_EDIT, - ProviderFeature.LIBRARY_TRACKS_EDIT, - ProviderFeature.LIBRARY_ARTISTS_EDIT, - ProviderFeature.LIBRARY_PLAYLISTS_EDIT, - ProviderFeature.ALBUM_METADATA, - ProviderFeature.TRACK_METADATA, - ProviderFeature.ARTIST_METADATA, - ProviderFeature.ARTIST_ALBUMS, - ProviderFeature.ARTIST_TOPTRACKS, - ProviderFeature.BROWSE, - ProviderFeature.SEARCH, - ProviderFeature.PLAYLIST_TRACKS_EDIT, - ProviderFeature.PLAYLIST_CREATE, - ProviderFeature.RECOMMENDATIONS, - ProviderFeature.SIMILAR_TRACKS, -} +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + from music_assistant import MusicAssistant + from music_assistant.models import ProviderInstanceType -@dataclass -class DeezerCredentials: - """Class for storing credentials.""" - - app_id: int - app_secret: str - access_token: str - - -CONF_ACCESS_TOKEN = "access_token" -CONF_ARL_TOKEN = "arl_token" -CONF_ACTION_AUTH = "auth" -DEEZER_AUTH_URL = "https://connect.deezer.com/oauth/auth.php" -RELAY_URL = "https://deezer.oauth.jonathanbangert.com/" -DEEZER_PERMS = "basic_access,email,offline_access,manage_library,\ -manage_community,delete_library,listening_history" -DEEZER_APP_ID = app_var(6) -DEEZER_APP_SECRET = app_var(7) - -# Virtual playlist IDs for dynamic Deezer content -FLOW_PLAYLIST_ID = "flow" -RECOMMENDED_TRACKS_PLAYLIST_ID = "recommended_tracks" -TOP_CHARTS_PLAYLIST_ID = "top_charts" -RADIO_PLAYLIST_PREFIX = "radio_" -MOOD_FLOW_PREFIX = "mood_flow_" - -# Curated Deezer radio station IDs -CURATED_RADIO_IDS = [ - 37151, # Hits - 38305, # The '80s - 38295, # The '70s - 31061, # Pop - 37765, # Rock classics - 30901, # Metal - 30991, # Hip Hop - 30771, # Indie - 30621, # Electronic - 31031, # Jazz - 30661, # Classical - 36791, # Latin Music - 38225, # Focus - 39041, # Happy Hour -] - - -async def get_access_token( - app_id: str, app_secret: str, code: str, http_session: ClientSession -) -> str: - """Update the access_token.""" - response = await http_session.post( - "https://connect.deezer.com/oauth/access_token.php", - params={"code": code, "app_id": app_id, "secret": app_secret}, - ssl=False, - ) - if response.status != 200: - msg = f"HTTP Error {response.status}: {response.reason}" - raise ConnectionError(msg) - response_text = await response.text() - try: - return response_text.split("=")[1].split("&")[0] - except Exception as error: - msg = "Invalid auth code" - raise LoginFailed(msg) from error +__all__ = ["DeezerProvider"] async def setup( @@ -150,1011 +27,20 @@ async def setup( async def get_config_entries( - mass: MusicAssistant, + mass: MusicAssistant, # noqa: ARG001 instance_id: str | None = None, # noqa: ARG001 - action: str | None = None, + action: str | None = None, # noqa: ARG001 values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: """Return Config entries to setup this provider.""" - # Action is to launch oauth flow - if action == CONF_ACTION_AUTH: - # Use the AuthenticationHelper to authenticate - if not values or "session_id" not in values: - raise InvalidDataError("session_id not found in values") - async with AuthenticationHelper(mass, cast("str", values["session_id"])) as auth_helper: - url = f"{DEEZER_AUTH_URL}?app_id={DEEZER_APP_ID}&redirect_uri={RELAY_URL}\ -&perms={DEEZER_PERMS}&state={auth_helper.callback_url}" - code = (await auth_helper.authenticate(url))["code"] - values[CONF_ACCESS_TOKEN] = await get_access_token( - DEEZER_APP_ID, DEEZER_APP_SECRET, code, mass.http_session - ) - return ( - ConfigEntry( - key=CONF_ACCESS_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Access token", - required=True, - action=CONF_ACTION_AUTH, - description="You need to authenticate on Deezer.", - action_label="Authenticate with Deezer", - value=values.get(CONF_ACCESS_TOKEN) if values else None, - ), ConfigEntry( key=CONF_ARL_TOKEN, type=ConfigEntryType.SECURE_STRING, - label="Arl token", + label="ARL token", required=True, - description="See https://www.dumpmedia.com/deezplus/deezer-arl.html", + description="Your Deezer ARL cookie token. " + "See https://www.dumpmedia.com/deezplus/deezer-arl.html", value=values.get(CONF_ARL_TOKEN) if values else None, ), ) - - -class DeezerProvider(MusicProvider): - """Deezer provider support.""" - - client: deezer.Client - gw_client: GWClient - credentials: DeezerCredentials - user: deezer.User - - async def handle_async_init(self) -> None: - """Handle async init of the Deezer provider.""" - self.credentials = DeezerCredentials( - app_id=DEEZER_APP_ID, - app_secret=DEEZER_APP_SECRET, - access_token=cast("str", self.config.get_value(CONF_ACCESS_TOKEN)), - ) - - self.client = deezer.Client( - app_id=self.credentials.app_id, - app_secret=self.credentials.app_secret, - access_token=self.credentials.access_token, - ) - - self.user = await self.client.get_user() - - self.gw_client = GWClient( - self.mass.http_session, - str(self.config.get_value(CONF_ACCESS_TOKEN)), - str(self.config.get_value(CONF_ARL_TOKEN)), - ) - await self.gw_client.setup() - - # Cached wrappers for dynamic Deezer content (ensures consistent data across calls) - @use_cache(3600) # Cache for 1 hour - async def _get_flow_tracks(self) -> list[deezer.Track]: - """Get cached Flow tracks.""" - return list(await self.client.get_user_flow()) - - @use_cache(3600) # Cache for 1 hour - async def _get_recommended_tracks(self) -> list[deezer.Track]: - """Get cached recommended tracks.""" - return list(await self.client.get_user_recommended_tracks()) - - @use_cache(3600) # Cache for 1 hour - async def _get_chart_tracks(self) -> list[deezer.Track]: - """Get cached chart tracks.""" - chart = await self.client.get_chart() - return list(chart.tracks[:100]) if chart.tracks else [] - - @use_cache(3600) # Cache for 1 hour - async def _get_mood_flow_tracks(self, config_id: str) -> list[dict[str, Any]]: - """Get cached mood/genre Flow tracks from the GW API. - - :param config_id: The Flow config identifier (e.g. "happy", "chill", "genre-rock"). - """ - return await self.gw_client.get_user_radio(config_id) - - @use_cache(3600 * 24) # Cache for 24 hours - async def _get_available_flows(self) -> list[tuple[str, str, str | None]]: - """Discover available mood/genre Flow variants from the Deezer home page. - - Genre flows have config_ids starting with 'genre-'. - Returns a list of (config_id, display_name, cover_url) tuples. - """ - items = await self.gw_client.get_home_flows() - flows: list[tuple[str, str, str | None]] = [] - for item in items: - config_id = item["data"]["id"] - if config_id == "default": - continue - title = f"Flow: {item['title']}" - cover_url = None - if pictures := item.get("pictures"): - cover_url = f"https://e-cdns-images.dzcdn.net/images/misc/{pictures[0]['md5']}/264x264-000000-80-0-0.jpg" - flows.append((config_id, title, cover_url)) - return flows - - @use_cache(3600 * 24 * 7) # Cache for 7 days - async def search( - self, search_query: str, media_types: list[MediaType], limit: int = 5 - ) -> SearchResults: - """Perform search on music provider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - """ - # Create a task for each media_type - tasks: dict[MediaType, Any] = {} - - async with TaskGroup() as taskgroup: - for media_type in media_types: - if media_type == MediaType.TRACK: - tasks[MediaType.TRACK] = taskgroup.create_task( - self.search_and_parse_tracks( - query=search_query, - limit=limit, - user_country=self.gw_client.user_country, - ) - ) - elif media_type == MediaType.ARTIST: - tasks[MediaType.ARTIST] = taskgroup.create_task( - self.search_and_parse_artists(query=search_query, limit=limit) - ) - elif media_type == MediaType.ALBUM: - tasks[MediaType.ALBUM] = taskgroup.create_task( - self.search_and_parse_albums(query=search_query, limit=limit) - ) - elif media_type == MediaType.PLAYLIST: - tasks[MediaType.PLAYLIST] = taskgroup.create_task( - self.search_and_parse_playlists(query=search_query, limit=limit) - ) - - results = SearchResults() - - for media_type, task in tasks.items(): - if media_type == MediaType.ARTIST: - results.artists = task.result() - elif media_type == MediaType.ALBUM: - results.albums = task.result() - elif media_type == MediaType.TRACK: - results.tracks = task.result() - elif media_type == MediaType.PLAYLIST: - results.playlists = task.result() - - return results - - async def get_library_artists(self) -> AsyncGenerator[Artist, None]: - """Retrieve all library artists from Deezer.""" - async for artist in await self.client.get_user_artists(): - item = self.parse_artist(artist=artist) - if time_add := getattr(artist, "time_add", None): - item.date_added = datetime.fromtimestamp(int(time_add), tz=UTC) - yield item - - async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve all library albums from Deezer.""" - async for album in await self.client.get_user_albums(): - item = self.parse_album(album=album) - if time_add := getattr(album, "time_add", None): - item.date_added = datetime.fromtimestamp(int(time_add), tz=UTC) - yield item - - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: - """Retrieve all library playlists from Deezer.""" - async for playlist in await self.user.get_playlists(): - item = self.parse_playlist(playlist=playlist) - if time_add := getattr(playlist, "time_add", None): - item.date_added = datetime.fromtimestamp(int(time_add), tz=UTC) - yield item - - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: - """Retrieve all library tracks from Deezer.""" - async for track in await self.client.get_user_tracks(): - yield self.parse_track(track=track, user_country=self.gw_client.user_country) - - @use_cache(3600 * 24 * 30) # Cache for 30 days - async def get_artist(self, prov_artist_id: str) -> Artist: - """Get full artist details by id.""" - try: - return self.parse_artist( - artist=await self.client.get_artist(artist_id=int(prov_artist_id)) - ) - except deezer_exceptions.DeezerErrorResponse as error: - self.logger.warning("Failed getting artist: %s", error) - raise MediaNotFoundError(f"Artist {prov_artist_id} not found on Deezer") from error - - @use_cache(3600 * 24 * 30) # Cache for 30 days - async def get_album(self, prov_album_id: str) -> Album: - """Get full album details by id.""" - try: - return self.parse_album(album=await self.client.get_album(album_id=int(prov_album_id))) - except deezer_exceptions.DeezerErrorResponse as error: - self.logger.warning("Failed getting album: %s", error) - raise MediaNotFoundError(f"Album {prov_album_id} not found on Deezer") from error - - @use_cache(3600 * 24 * 30) # Cache for 30 days - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get full playlist details by id.""" - # Handle virtual playlists (Flow, Recommended tracks, Top Charts, Radios) - if prov_playlist_id == FLOW_PLAYLIST_ID: - flow_tracks = await self._get_flow_tracks() - flow_cover = None - if flow_tracks and hasattr(flow_tracks[0], "album"): - flow_cover = getattr(flow_tracks[0].album, "cover_medium", None) - return self._create_virtual_playlist(FLOW_PLAYLIST_ID, "Flow", image_url=flow_cover) - if prov_playlist_id == RECOMMENDED_TRACKS_PLAYLIST_ID: - rec_tracks = await self._get_recommended_tracks() - rec_cover = None - if rec_tracks and hasattr(rec_tracks[0], "album"): - rec_cover = getattr(rec_tracks[0].album, "cover_medium", None) - return self._create_virtual_playlist( - RECOMMENDED_TRACKS_PLAYLIST_ID, "Recommended tracks", image_url=rec_cover - ) - if prov_playlist_id == TOP_CHARTS_PLAYLIST_ID: - chart_tracks = await self._get_chart_tracks() - chart_cover = None - if chart_tracks and hasattr(chart_tracks[0], "album"): - chart_cover = getattr(chart_tracks[0].album, "cover_medium", None) - return self._create_virtual_playlist( - TOP_CHARTS_PLAYLIST_ID, "Top Charts", image_url=chart_cover - ) - if prov_playlist_id.startswith(RADIO_PLAYLIST_PREFIX): - radio_id = int(prov_playlist_id.replace(RADIO_PLAYLIST_PREFIX, "")) - try: - radio = await self.client.get_radio(radio_id) - return self._create_virtual_playlist( - prov_playlist_id, - f"Radio: {radio.title}", - image_url=getattr(radio, "picture_medium", None), - ) - except Exception as err: - self.logger.warning("Failed getting radio %s: %s", radio_id, err) - raise MediaNotFoundError(f"Radio {prov_playlist_id} not found on Deezer") from err - if prov_playlist_id.startswith(MOOD_FLOW_PREFIX): - config_id = prov_playlist_id.removeprefix(MOOD_FLOW_PREFIX) - all_flows = await self._get_available_flows() - flow_info = {cid: (name, cover) for cid, name, cover in all_flows} - name, cover_url = flow_info.get(config_id, (f"Flow: {config_id}", None)) - return self._create_virtual_playlist(prov_playlist_id, name, image_url=cover_url) - try: - return self.parse_playlist( - playlist=await self.client.get_playlist(playlist_id=int(prov_playlist_id)), - ) - except deezer_exceptions.DeezerErrorResponse as error: - self.logger.warning("Failed getting playlist: %s", error) - raise MediaNotFoundError(f"Album {prov_playlist_id} not found on Deezer") from error - - @use_cache(3600 * 24 * 30) # Cache for 30 days - async def get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - try: - return self.parse_track( - track=await self.client.get_track(track_id=int(prov_track_id)), - user_country=self.gw_client.user_country, - ) - except deezer_exceptions.DeezerErrorResponse as error: - self.logger.warning("Failed getting track: %s", error) - raise MediaNotFoundError(f"Album {prov_track_id} not found on Deezer") from error - - @use_cache(3600 * 24 * 30, allow_expired_cache=True) # Cache for 30 days - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: - """Get all tracks in an album.""" - album = await self.client.get_album(album_id=int(prov_album_id)) - return [ - self.parse_track( - track=deezer_track, - user_country=self.gw_client.user_country, - # TODO: doesn't Deezer have disc and track number in the api ? - position=0, - ) - for deezer_track in await album.get_tracks() - ] - - async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: - """Get playlist tracks.""" - if page > 0: - # paging not supported, we always return the whole list at once - return [] - - # Virtual playlists use their own cached wrappers (not double-cached) - if prov_playlist_id == FLOW_PLAYLIST_ID: - return self._parse_tracks_list(await self._get_flow_tracks()) - - if prov_playlist_id == RECOMMENDED_TRACKS_PLAYLIST_ID: - return self._parse_tracks_list(await self._get_recommended_tracks()) - - if prov_playlist_id == TOP_CHARTS_PLAYLIST_ID: - return self._parse_tracks_list(await self._get_chart_tracks()) - - if prov_playlist_id.startswith(RADIO_PLAYLIST_PREFIX): - radio_id = int(prov_playlist_id.replace(RADIO_PLAYLIST_PREFIX, "")) - try: - radio = await self.client.get_radio(radio_id) - return self._parse_tracks_list(list(await radio.get_tracks())) - except Exception as err: - self.logger.debug("Failed to get radio tracks %s: %s", radio_id, err) - return [] - - if prov_playlist_id.startswith(MOOD_FLOW_PREFIX): - config_id = prov_playlist_id.removeprefix(MOOD_FLOW_PREFIX) - gw_tracks = await self._get_mood_flow_tracks(config_id) - return [await self.get_track(track["SNG_ID"]) for track in gw_tracks] - - # Regular Deezer playlists (cached separately) - return await self._get_regular_playlist_tracks(prov_playlist_id) - - @use_cache(3600 * 3) # Cache for 3 hours - async def _get_regular_playlist_tracks(self, prov_playlist_id: str) -> list[Track]: - """Get tracks for regular Deezer playlists (cached).""" - playlist = await self.client.get_playlist(int(prov_playlist_id)) - playlist_tracks = await playlist.get_tracks() - return self._parse_tracks_list(list(playlist_tracks)) - - def _parse_tracks_list(self, tracks: list[deezer.Track]) -> list[Track]: - """Parse a list of Deezer tracks to Music Assistant tracks.""" - return [ - self.parse_track( - track=track, - user_country=self.gw_client.user_country, - position=index, - ) - for index, track in enumerate(tracks, 1) - ] - - @use_cache(3600 * 24 * 7, allow_expired_cache=True) # Cache for 7 days - async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: - """Get albums by an artist.""" - artist = await self.client.get_artist(artist_id=int(prov_artist_id)) - return [self.parse_album(album=album) async for album in await artist.get_albums()] - - @use_cache(3600 * 24 * 7, allow_expired_cache=True) # Cache for 7 days - async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: - """Get top 50 tracks of an artist.""" - artist = await self.client.get_artist(artist_id=int(prov_artist_id)) - return [ - self.parse_track(track=track, user_country=self.gw_client.user_country) - async for track in await artist.get_top(limit=50) - ] - - async def library_add(self, item: MediaItemType) -> bool: - """Add an item to the provider's library/favorites.""" - result = False - if item.media_type == MediaType.ARTIST: - result = bool( - await self.client.add_user_artist( - artist_id=int(item.item_id), - ) - ) - elif item.media_type == MediaType.ALBUM: - result = bool( - await self.client.add_user_album( - album_id=int(item.item_id), - ) - ) - elif item.media_type == MediaType.TRACK: - result = bool( - await self.client.add_user_track( - track_id=int(item.item_id), - ) - ) - elif item.media_type == MediaType.PLAYLIST: - result = bool( - await self.client.add_user_playlist( - playlist_id=int(item.item_id), - ) - ) - else: - raise NotImplementedError - return result - - async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: - """Remove an item from the provider's library/favorites.""" - result = False - if media_type == MediaType.ARTIST: - result = bool( - await self.client.remove_user_artist( - artist_id=int(prov_item_id), - ) - ) - elif media_type == MediaType.ALBUM: - result = bool( - await self.client.remove_user_album( - album_id=int(prov_item_id), - ) - ) - elif media_type == MediaType.TRACK: - result = bool( - await self.client.remove_user_track( - track_id=int(prov_item_id), - ) - ) - elif media_type == MediaType.PLAYLIST: - result = bool( - await self.client.remove_user_playlist( - playlist_id=int(prov_item_id), - ) - ) - else: - raise NotImplementedError - return result - - @use_cache(3600) - async def recommendations(self) -> list[RecommendationFolder]: - """Get Deezer's recommendations including Flow and personalized content.""" - result: list[RecommendationFolder] = [] - - # Made for you - combines Flow, Recommended tracks, and recommended playlists - # Get covers from first track's album for each virtual playlist - flow_cover = None - flow_tracks = await self._get_flow_tracks() - if flow_tracks and hasattr(flow_tracks[0], "album"): - flow_cover = getattr(flow_tracks[0].album, "cover_medium", None) - - recommended_cover = None - recommended_tracks = await self._get_recommended_tracks() - if recommended_tracks and hasattr(recommended_tracks[0], "album"): - recommended_cover = getattr(recommended_tracks[0].album, "cover_medium", None) - - chart_tracks = await self._get_chart_tracks() - chart_cover = None - if chart_tracks and hasattr(chart_tracks[0], "album"): - chart_cover = getattr(chart_tracks[0].album, "cover_medium", None) - - made_for_you_items: list[Playlist] = [ - # Flow - personalized endless radio - self._create_virtual_playlist(FLOW_PLAYLIST_ID, "Flow", image_url=flow_cover), - # Recommended tracks - self._create_virtual_playlist( - RECOMMENDED_TRACKS_PLAYLIST_ID, "Recommended tracks", image_url=recommended_cover - ), - # Top Charts - global top tracks - self._create_virtual_playlist( - TOP_CHARTS_PLAYLIST_ID, "Top Charts", image_url=chart_cover - ), - ] - # Add recommended playlists from Deezer - for playlist in await self.client.get_user_recommended_playlists(): - made_for_you_items.append(self.parse_playlist(playlist=playlist)) - - result.append( - RecommendationFolder( - item_id="made_for_you", - provider=self.instance_id, - name="Made for you", - items=UniqueList(made_for_you_items), - ) - ) - - # Recommended albums - try: - recommended_albums = list(await self.client.get_user_recommended_albums()) - if recommended_albums: - result.append( - RecommendationFolder( - item_id="recommended_albums", - provider=self.instance_id, - name="Recommended albums", - items=UniqueList( - [self.parse_album(album=album) for album in recommended_albums] - ), - ) - ) - except deezer_exceptions.DeezerErrorResponse as err: - self.logger.debug("Failed to get recommended albums: %s", err) - - # Recommended artists - try: - recommended_artists = list(await self.client.get_user_recommended_artists()) - if recommended_artists: - result.append( - RecommendationFolder( - item_id="recommended_artists", - provider=self.instance_id, - name="Recommended artists", - items=UniqueList( - [self.parse_artist(artist=artist) for artist in recommended_artists] - ), - ) - ) - except deezer_exceptions.DeezerErrorResponse as err: - self.logger.debug("Failed to get recommended artists: %s", err) - - # Deezer Mood and Genre Flows - personalized playlists (dynamically discovered) - all_flows = await self._get_available_flows() - mood_flows = [(c, n, img) for c, n, img in all_flows if not c.startswith("genre-")] - genre_flows = [(c, n, img) for c, n, img in all_flows if c.startswith("genre-")] - for folder_id, folder_name, flows in [ - ("mood_flows", "Deezer Mood Flows", mood_flows), - ("genre_flows", "Deezer Genre Flows", genre_flows), - ]: - flow_playlists = [ - self._create_virtual_playlist( - item_id=f"{MOOD_FLOW_PREFIX}{config_id}", - name=display_name, - image_url=cover_url, - ) - for config_id, display_name, cover_url in flows - ] - if flow_playlists: - result.append( - RecommendationFolder( - item_id=folder_id, - provider=self.instance_id, - name=folder_name, - items=UniqueList(flow_playlists), - ) - ) - - # Deezer Radios - curated selection (as virtual playlists in one folder) - radio_playlists: list[Playlist] = [] - for radio_id in CURATED_RADIO_IDS: - try: - radio = await self.client.get_radio(radio_id) - radio_playlists.append( - self._create_virtual_playlist( - item_id=f"{RADIO_PLAYLIST_PREFIX}{radio_id}", - name=f"Radio: {radio.title}", - image_url=getattr(radio, "picture_medium", None), - ) - ) - except Exception as err: - self.logger.debug("Failed to load radio %s: %s", radio_id, err) - - if radio_playlists: - result.append( - RecommendationFolder( - item_id="radios", - provider=self.instance_id, - name="Deezer Radios", - items=UniqueList(radio_playlists), - ) - ) - - return result - - async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: - """Add track(s) to playlist.""" - playlist = await self.client.get_playlist(int(prov_playlist_id)) - await playlist.add_tracks(tracks=[int(i) for i in prov_track_ids]) - - async def remove_playlist_tracks( - self, prov_playlist_id: str, positions_to_remove: tuple[int, ...] - ) -> None: - """Remove track(s) from playlist.""" - playlist_track_ids = [] - for track in await self.get_playlist_tracks(prov_playlist_id, 0): - if track.position in positions_to_remove: - playlist_track_ids.append(int(track.item_id)) - if len(playlist_track_ids) == len(positions_to_remove): - break - playlist = await self.client.get_playlist(int(prov_playlist_id)) - await playlist.delete_tracks(playlist_track_ids) - - async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist: - """Create a new playlist on provider with given name.""" - playlist_id = await self.client.create_playlist(playlist_name=name) - playlist = await self.client.get_playlist(playlist_id) - return self.parse_playlist(playlist=playlist) - - @use_cache(3600 * 24, allow_expired_cache=True) # Cache for 24 hours - async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: - """Retrieve a dynamic list of tracks based on the provided item.""" - endpoint = "song.getSearchTrackMix" - tracks = (await self.gw_client._gw_api_call(endpoint, args={"SNG_ID": prov_track_id}))[ - "results" - ]["data"][:limit] - return [await self.get_track(track["SNG_ID"]) for track in tracks] - - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - url_details, song_data = await self.gw_client.get_deezer_track_urls(item_id) - url = url_details["sources"][0]["url"] - return StreamDetails( - item_id=item_id, - provider=self.instance_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse(url_details["format"].split("_")[0]) - ), - stream_type=StreamType.CUSTOM, - duration=int(song_data["DURATION"]), - # Due to track replacement, the track ID of the stream may be different from the ID - # that is stored. We need the proper track ID to decrypt the stream, so store it - # separately so we can use it later on. - data={"url": url, "format": url_details["format"], "track_id": song_data["SNG_ID"]}, - size=int(song_data[f"FILESIZE_{url_details['format']}"]), - can_seek=True, - allow_seek=True, - ) - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item.""" - blowfish_key = self.get_blowfish_key(streamdetails.data["track_id"]) - chunk_index = 0 - timeout = ClientTimeout(total=None, connect=30, sock_read=600) - headers: dict[str, str] = {} - # if seek_position and streamdetails.size: - # chunk_count = ceil(streamdetails.size / 2048) - # chunk_index = int(chunk_count / streamdetails.duration) * seek_position - # skip_bytes = chunk_index * 2048 - # headers["Range"] = f"bytes={skip_bytes}-" - - # NOTE: Seek with using the Range header is not working properly - # causing malformed audio so this is a temporary patch - # by just skipping chunks - if seek_position and streamdetails.size and streamdetails.duration: - chunk_count = ceil(streamdetails.size / 2048) - skip_chunks = int(chunk_count / streamdetails.duration) * seek_position - else: - skip_chunks = 0 - - buffer = bytearray() - streamdetails.data["start_ts"] = utc_timestamp() - streamdetails.data["stream_id"] = uuid.uuid1() - self.mass.create_task(self.gw_client.log_listen(next_track=streamdetails.item_id)) - async with self.mass.http_session.get( - streamdetails.data["url"], headers=headers, timeout=timeout - ) as resp: - async for chunk in resp.content.iter_chunked(2048): - buffer += chunk - if len(buffer) >= 2048: - if chunk_index >= skip_chunks or chunk_index == 0: - if chunk_index % 3 > 0: - yield bytes(buffer[:2048]) - else: - yield self.decrypt_chunk(bytes(buffer[:2048]), blowfish_key) - - chunk_index += 1 - del buffer[:2048] - yield bytes(buffer) - - async def on_streamed( - self, - streamdetails: StreamDetails, - ) -> None: - """Handle callback when an item completed streaming.""" - await self.gw_client.log_listen(last_track=streamdetails) - - ### PARSING METADATA FUNCTIONS ### - - def parse_metadata_track(self, track: deezer.Track) -> MediaItemMetadata: - """Parse the track metadata.""" - metadata = MediaItemMetadata() - if hasattr(track, "preview"): - metadata.preview = track.preview - if hasattr(track, "explicit_lyrics"): - metadata.explicit = track.explicit_lyrics - if hasattr(track, "rank"): - metadata.popularity = track.rank - if hasattr(track, "album") and hasattr(track.album, "cover_big"): - metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=track.album.cover_big, - provider=self.instance_id, - remotely_accessible=True, - ) - ) - return metadata - - def parse_metadata_album(self, album: deezer.Album) -> MediaItemMetadata: - """Parse the album metadata.""" - return MediaItemMetadata( - explicit=album.explicit_lyrics, - images=UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=album.cover_big, - provider=self.instance_id, - remotely_accessible=True, - ) - ] - ), - ) - - def parse_metadata_artist(self, artist: deezer.Artist) -> MediaItemMetadata: - """Parse the artist metadata.""" - return MediaItemMetadata( - images=UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=artist.picture_big, - provider=self.instance_id, - remotely_accessible=True, - ) - ] - ), - ) - - ### PARSING FUNCTIONS ### - def parse_artist(self, artist: deezer.Artist) -> Artist: - """Parse the deezer-python artist to a Music Assistant artist.""" - return Artist( - item_id=str(artist.id), - provider=self.instance_id, - name=artist.name, - media_type=MediaType.ARTIST, - provider_mappings={ - ProviderMapping( - item_id=str(artist.id), - provider_domain=self.domain, - provider_instance=self.instance_id, - url=getattr(artist, "link", None), # Sometimes the API doesn't return a link - ) - }, - metadata=self.parse_metadata_artist(artist=artist), - ) - - def parse_album(self, album: deezer.Album) -> Album: - """Parse the deezer-python album to a Music Assistant album.""" - name, version = parse_title_and_version(album.title) - return Album( - album_type=self.get_album_type(album), - item_id=str(album.id), - provider=self.instance_id, - name=name, - version=version, - year=album.release_date.year if getattr(album, "release_date", None) else None, - artists=UniqueList( - [ - ItemMapping( - media_type=MediaType.ARTIST, - item_id=str(album.artist.id), - provider=self.instance_id, - name=album.artist.name, - ) - ] - ), - media_type=MediaType.ALBUM, - provider_mappings={ - ProviderMapping( - item_id=str(album.id), - provider_domain=self.domain, - provider_instance=self.instance_id, - url=getattr(album, "link", None), - ) - }, - metadata=self.parse_metadata_album(album=album), - ) - - def parse_playlist(self, playlist: deezer.Playlist) -> Playlist: - """Parse the deezer-python playlist to a Music Assistant playlist.""" - creator = self.get_playlist_creator(playlist) - is_editable = creator.id == self.user.id - return Playlist( - item_id=str(playlist.id), - provider=self.instance_id, - name=playlist.title, - media_type=MediaType.PLAYLIST, - provider_mappings={ - ProviderMapping( - item_id=str(playlist.id), - provider_domain=self.domain, - provider_instance=self.instance_id, - url=getattr(playlist, "link", None), - is_unique=is_editable, # user-owned playlists are unique - ) - }, - metadata=MediaItemMetadata( - images=UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=playlist.picture_big, - provider=self.instance_id, - remotely_accessible=True, - ) - ] - ), - ), - is_editable=is_editable, - owner=creator.name, - ) - - def get_playlist_creator(self, playlist: deezer.Playlist) -> deezer.User: - """On playlists, the creator is called creator, elsewhere it's called user.""" - if hasattr(playlist, "creator"): - return playlist.creator - return playlist.user - - def _create_virtual_playlist( - self, - item_id: str, - name: str, - image_url: str | None = None, - ) -> Playlist: - """Create a virtual playlist for Flow, Recommended tracks, or Radios. - - :param item_id: The unique identifier (e.g., "flow", "radio_37151"). - :param name: Display name for the playlist. - :param image_url: Optional image URL. - """ - images: UniqueList[MediaItemImage] = UniqueList() - if image_url: - images.append( - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.instance_id, - remotely_accessible=True, - ) - ) - return Playlist( - item_id=item_id, - provider=self.instance_id, - name=name, - media_type=MediaType.PLAYLIST, - provider_mappings={ - ProviderMapping( - item_id=item_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - ) - }, - metadata=MediaItemMetadata(images=images) if images else MediaItemMetadata(), - is_editable=False, - owner="Deezer", - ) - - def parse_track(self, track: deezer.Track, user_country: str, position: int = 0) -> Track: - """Parse the deezer-python track to a Music Assistant track.""" - if hasattr(track, "artist"): - artist = ItemMapping( - media_type=MediaType.ARTIST, - item_id=str(getattr(track.artist, "id", f"deezer-{track.artist.name}")), - provider=self.instance_id, - name=track.artist.name, - ) - else: - artist = None - if hasattr(track, "album"): - album = ItemMapping( - media_type=MediaType.ALBUM, - item_id=str(track.album.id), - provider=self.instance_id, - name=track.album.title, - ) - else: - album = None - - name, version = parse_title_and_version(track.title) - item = Track( - item_id=str(track.id), - provider=self.instance_id, - name=name, - version=version, - sort_name=self.get_short_title(track), - duration=track.duration, - artists=UniqueList([artist]) if artist else UniqueList(), - album=album, - provider_mappings={ - ProviderMapping( - item_id=str(track.id), - provider_domain=self.domain, - provider_instance=self.instance_id, - available=self.track_available(track=track, user_country=user_country), - url=getattr(track, "link", None), - ) - }, - metadata=self.parse_metadata_track(track=track), - track_number=getattr(track, "track_position", position), - position=position, - disc_number=getattr(track, "disk_number", 0), - ) - if isrc := getattr(track, "isrc", None): - item.external_ids.add((ExternalID.ISRC, isrc)) - if time_add := getattr(track, "time_add", None): - item.date_added = datetime.fromtimestamp(int(time_add), tz=UTC) - return item - - def get_short_title(self, track: deezer.Track) -> str: - """Short names only returned, if available.""" - if hasattr(track, "title_short"): - return str(track.title_short) - return str(track.title) - - def get_album_type(self, album: deezer.Album) -> AlbumType: - """Read and convert the Deezer album type.""" - # Get provider's basic type first - provider_type = AlbumType.UNKNOWN - if hasattr(album, "record_type"): - match album.record_type: - case "album": - provider_type = AlbumType.ALBUM - case "single": - provider_type = AlbumType.SINGLE - case "ep": - provider_type = AlbumType.EP - case "compile": - provider_type = AlbumType.COMPILATION - - # Try inference - override if it finds something more specific - inferred_type = infer_album_type(album.title, "") - if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE): - return inferred_type - - # Otherwise use provider type - return provider_type - - ### SEARCH AND PARSE FUNCTIONS ### - async def search_and_parse_tracks( - self, query: str, user_country: str, limit: int = 20 - ) -> list[Track]: - """Search for tracks and parse them.""" - deezer_tracks = await self.client.search(query=query, limit=limit) - tracks = [] - for index, track in enumerate(deezer_tracks): - tracks.append(self.parse_track(track, user_country)) - if index == limit: - return tracks - return tracks - - async def search_and_parse_artists(self, query: str, limit: int = 20) -> list[Artist]: - """Search for artists and parse them.""" - deezer_artist = await self.client.search_artists(query=query, limit=limit) - artists = [] - for index, artist in enumerate(deezer_artist): - artists.append(self.parse_artist(artist)) - if index == limit: - return artists - return artists - - async def search_and_parse_albums(self, query: str, limit: int = 20) -> list[Album]: - """Search for album and parse them.""" - deezer_albums = await self.client.search_albums(query=query, limit=limit) - albums = [] - for index, album in enumerate(deezer_albums): - albums.append(self.parse_album(album)) - if index == limit: - return albums - return albums - - async def search_and_parse_playlists(self, query: str, limit: int = 20) -> list[Playlist]: - """Search for playlists and parse them.""" - deezer_playlists = await self.client.search_playlists(query=query, limit=limit) - playlists = [] - for index, playlist in enumerate(deezer_playlists): - playlists.append(self.parse_playlist(playlist)) - if index == limit: - return playlists - return playlists - - ### OTHER FUNCTIONS ### - - async def get_track_content_type( - self, gw_client: GWClient, track_id: str - ) -> Literal[ContentType.FLAC, ContentType.MP3]: - """Get a tracks contentType.""" - song_data = await gw_client.get_song_data(track_id) - if song_data["results"]["FILESIZE_FLAC"]: - return ContentType.FLAC - - if song_data["results"]["FILESIZE_MP3_320"] or song_data["results"]["FILESIZE_MP3_128"]: - return ContentType.MP3 - - msg = "Unsupported contenttype" - raise NotImplementedError(msg) - - def track_available(self, track: deezer.Track, user_country: str) -> bool: - """Check if a given track is available in the users country.""" - if hasattr(track, "available_countries"): - return user_country in track.available_countries - return True - - def _md5(self, data: str, data_type: str = "ascii") -> str: - md5sum = hashlib.md5() - md5sum.update(data.encode(data_type)) - return md5sum.hexdigest() - - def get_blowfish_key(self, track_id: str) -> str: - """Get blowfish key to decrypt a chunk of a track.""" - secret = app_var(5) - id_md5 = self._md5(track_id) - return "".join( - chr(ord(id_md5[i]) ^ ord(id_md5[i + 16]) ^ ord(secret[i])) for i in range(16) - ) - - def decrypt_chunk(self, chunk: bytes, blowfish_key: str) -> bytes: - """Decrypt a given chunk using the blow fish key.""" - cipher = Blowfish.new( - blowfish_key.encode("ascii"), - Blowfish.MODE_CBC, - b"\x00\x01\x02\x03\x04\x05\x06\x07", - ) - return cipher.decrypt(chunk) # type: ignore[no-any-return,unused-ignore] diff --git a/music_assistant/providers/deezer/browse.py b/music_assistant/providers/deezer/browse.py new file mode 100644 index 0000000000..5cf45d18a5 --- /dev/null +++ b/music_assistant/providers/deezer/browse.py @@ -0,0 +1,1094 @@ +""" +Browse and recommendations manager for the Deezer provider. + +Handles browse tree routing, recommendation folders, virtual playlist +infrastructure, and all track-fetching methods for virtual playlists. +""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine, Sequence +from typing import TYPE_CHECKING + +from deezer_python_gql.generated.enums import ( + MusicTogetherRefreshSuggestedTracklistMoodInput, + MusicTogetherSuggestedTracklistMoodInput, +) +from deezer_python_gql.generated.get_made_for_me import ( + GetMadeForMeMeMadeForMeEdgesNodeSmartTracklist, +) +from music_assistant_models.media_items import ( + BrowseFolder, + ItemMapping, + MediaItemType, + Playlist, + RecommendationFolder, + Track, + UniqueList, +) + +from music_assistant.controllers.cache import use_cache + +from .constants import ( + BROWSE_ALL_FLOWS, + BROWSE_AUDIOBOOKS, + BROWSE_EXPLORE, + BROWSE_GENRES, + BROWSE_MADE_FOR_YOU, + BROWSE_MOODS, + BROWSE_PERSONALIZED_PLAYLISTS, + BROWSE_RECENTLY_PLAYED, + BROWSE_RECOMMENDED_ARTIST_PLAYLISTS, + BROWSE_RECOMMENDED_PLAYLISTS, + BROWSE_SHAKER, + BROWSE_TOP_ALBUMS, + BROWSE_TOP_ARTISTS, + BROWSE_TOP_PLAYLISTS, + BROWSE_YOUR_TOP_ALBUMS, + BROWSE_YOUR_TOP_ARTISTS, + FLOW_CONFIG_PREFIX, + FLOW_PLAYLIST_ID, + PERSONAL_SONGS_PLAYLIST_ID, + RECOMMENDED_TRACKS_PLAYLIST_ID, + SHAKER_CURATED_PREFIX, + SHAKER_MIX_COVER, + SHAKER_PREFIX, + SMART_TRACKLIST_PREFIX, + TOP_CHARTS_PLAYLIST_ID, + USER_TOP_TRACKS_PLAYLIST_ID, +) +from .helpers import ( + create_virtual_playlist, +) +from .parsers import ( + get_flow_config_image, + get_gw_item_image, + parse_album, + parse_artist, + parse_gw_item, + parse_gw_track, + parse_playlist, + parse_recently_played_edges, + parse_track, +) + +if TYPE_CHECKING: + from deezer_python_gql.generated.get_flow_configs import ( + GetFlowConfigsMeFlowConfigsGenresEdges, + GetFlowConfigsMeFlowConfigsMoodsEdges, + ) + from deezer_python_gql.generated.get_recommendations import GetRecommendationsMe + from deezer_python_gql.generated.search_flows import ( + SearchFlowsSearchResultsFlowConfigsEdges, + ) + + from .provider import DeezerProvider + +AUDIOBOOKS_CHANNEL = "channels/audiobooks" + + +class DeezerBrowseManager: + """Handles browse tree, recommendations, and virtual playlist content.""" + + def __init__(self, provider: DeezerProvider) -> None: + """Initialize browse manager.""" + self.provider = provider + self.mass = provider.mass + self.instance_id = provider.instance_id + self.domain = provider.domain + self.logger = provider.logger + self._browse_slug_cache: dict[str, str] = {} + + # -- Browse routing -- + + async def browse( + self, + path: str, + base_browse: Callable[ + [str], Coroutine[None, None, Sequence[MediaItemType | ItemMapping | BrowseFolder]] + ], + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """ + Browse Deezer content. + + :param path: The browse path. + :param base_browse: Coroutine for the base class browse method. + """ + path_parts = path.split("://")[1].split("/") if "://" in path else [] + subpath = path_parts[0] if path_parts else None + sub_subpath = path_parts[1] if len(path_parts) > 1 else None + + if subpath == BROWSE_MADE_FOR_YOU: + return await self._browse_made_for_you(path, sub_subpath) + + if subpath == BROWSE_EXPLORE: + if sub_subpath: + return await self._browse_explore_category(sub_subpath) + return await self._browse_explore_root(path) + + if subpath == BROWSE_RECENTLY_PLAYED: + return await self._get_recently_played_items() + + if subpath == BROWSE_SHAKER: + if sub_subpath: + group_id = self._browse_slug_cache.get(f"shaker/{sub_subpath}", sub_subpath) + return await self._browse_shaker_group(group_id) + return await self._browse_shaker_root(path) + + if subpath == BROWSE_AUDIOBOOKS: + if sub_subpath: + page_path = self._browse_slug_cache.get( + f"audiobooks/{sub_subpath}", f"channels/{sub_subpath}" + ) + return await self._browse_audiobooks_page(page_path) + return await self._browse_audiobooks_root(path) + + if not subpath: + # Root: add custom folders alongside standard ones + # Filter out the Recommendations folder — our custom folders cover that content + base_items = [ + item + for item in await base_browse(path) + if not (isinstance(item, BrowseFolder) and item.item_id == "recommendations") + ] + base = path if path.endswith("//") else path.rstrip("/") + "/" + base_items.extend( + [ + BrowseFolder( + item_id="made_for_me", + provider=self.instance_id, + path=f"{base}{BROWSE_MADE_FOR_YOU}", + name=BROWSE_MADE_FOR_YOU, + ), + BrowseFolder( + item_id="explore", + provider=self.instance_id, + path=f"{base}{BROWSE_EXPLORE}", + name=BROWSE_EXPLORE, + ), + BrowseFolder( + item_id="recently_played", + provider=self.instance_id, + path=f"{base}{BROWSE_RECENTLY_PLAYED}", + name=BROWSE_RECENTLY_PLAYED, + ), + BrowseFolder( + item_id="shaker", + provider=self.instance_id, + path=f"{base}{BROWSE_SHAKER}", + name=BROWSE_SHAKER, + ), + BrowseFolder( + item_id="discover_audiobooks", + provider=self.instance_id, + path=f"{base}{BROWSE_AUDIOBOOKS}", + name=BROWSE_AUDIOBOOKS, + ), + create_virtual_playlist( + self.provider, PERSONAL_SONGS_PLAYLIST_ID, "My Uploads" + ), + ] + ) + return base_items + + # Standard paths handled by base class + return list(await base_browse(path)) + + # -- Made For You -- + + async def _browse_made_for_you( + self, path: str, sub_subpath: str | None + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Route Made For You sub-paths or return the root listing.""" + if sub_subpath in (BROWSE_MOODS, BROWSE_GENRES): + return await self._browse_flow_configs(sub_subpath.lower()) + if sub_subpath == BROWSE_YOUR_TOP_ARTISTS: + return await self._browse_user_charts_category("your_top_artists") + if sub_subpath == BROWSE_YOUR_TOP_ALBUMS: + return await self._browse_user_charts_category("your_top_albums") + if sub_subpath == BROWSE_RECOMMENDED_PLAYLISTS: + return await self._browse_editorial_playlists() + if sub_subpath == BROWSE_RECOMMENDED_ARTIST_PLAYLISTS: + return await self._browse_artist_playlists() + if sub_subpath == BROWSE_PERSONALIZED_PLAYLISTS: + return await self._get_smart_tracklist_playlists() + return await self._browse_made_for_me(path) + + async def _browse_made_for_me(self, path: str) -> list[MediaItemType | BrowseFolder]: + """Return Made For You sub-items: Moods, Genres, Top stats, Mixes, Playlists.""" + base = path if path.endswith("/") else path + "/" + items: list[MediaItemType | BrowseFolder] = [ + BrowseFolder( + item_id="moods", + provider=self.instance_id, + path=f"{base}{BROWSE_MOODS}", + name=BROWSE_MOODS, + ), + BrowseFolder( + item_id="genres", + provider=self.instance_id, + path=f"{base}{BROWSE_GENRES}", + name=BROWSE_GENRES, + ), + create_virtual_playlist(self.provider, USER_TOP_TRACKS_PLAYLIST_ID, "Your Top Tracks"), + create_virtual_playlist(self.provider, RECOMMENDED_TRACKS_PLAYLIST_ID, "Hot Tracks"), + BrowseFolder( + item_id="your_top_artists", + provider=self.instance_id, + path=f"{base}{BROWSE_YOUR_TOP_ARTISTS}", + name=BROWSE_YOUR_TOP_ARTISTS, + ), + BrowseFolder( + item_id="your_top_albums", + provider=self.instance_id, + path=f"{base}{BROWSE_YOUR_TOP_ALBUMS}", + name=BROWSE_YOUR_TOP_ALBUMS, + ), + BrowseFolder( + item_id="mixes", + provider=self.instance_id, + path=f"{base}{BROWSE_PERSONALIZED_PLAYLISTS}", + name=BROWSE_PERSONALIZED_PLAYLISTS, + ), + BrowseFolder( + item_id="recommended_playlists", + provider=self.instance_id, + path=f"{base}{BROWSE_RECOMMENDED_PLAYLISTS}", + name=BROWSE_RECOMMENDED_PLAYLISTS, + ), + BrowseFolder( + item_id="recommended_artist_playlists", + provider=self.instance_id, + path=f"{base}{BROWSE_RECOMMENDED_ARTIST_PLAYLISTS}", + name=BROWSE_RECOMMENDED_ARTIST_PLAYLISTS, + ), + ] + return items + + async def _browse_editorial_playlists(self) -> list[Playlist]: + """Fetch personalized editorial playlists from Deezer recommendations.""" + recs = await self.provider.gql_client.get_recommendations( + playlists_first=50, + artist_playlists_first=0, + new_releases_first=0, + artists_first=0, + hot_tracks_limit=0, + ) + if not recs or not recs.recommendations: + return [] + return [ + parse_playlist(self.provider, edge.node) + for edge in recs.recommendations.playlists.edges + if edge.node is not None + ] + + async def _browse_artist_playlists(self) -> list[Playlist]: + """Fetch personalized artist playlists from Deezer recommendations.""" + recs = await self.provider.gql_client.get_recommendations( + playlists_first=0, + artist_playlists_first=50, + new_releases_first=0, + artists_first=0, + hot_tracks_limit=0, + ) + if not recs or not recs.recommendations: + return [] + return [ + parse_playlist(self.provider, edge.node) + for edge in recs.recommendations.artist_playlists.edges + if edge.node is not None + ] + + # -- Flow configs -- + + def _flow_configs_to_playlists( + self, + edges: Sequence[ + GetFlowConfigsMeFlowConfigsMoodsEdges + | GetFlowConfigsMeFlowConfigsGenresEdges + | SearchFlowsSearchResultsFlowConfigsEdges + ], + ) -> list[Playlist]: + """Convert FlowConfig edges to virtual playlists.""" + return [ + create_virtual_playlist( + self.provider, + f"{FLOW_CONFIG_PREFIX}{edge.node.id}", + f"Flow: {edge.node.title}", + image_url=get_flow_config_image(edge.node), + ) + for edge in edges + if edge.node is not None + ] + + async def _browse_flow_configs(self, category: str) -> list[Playlist]: + """ + Fetch mood or genre flow configs and return as virtual playlists. + + :param category: Either "moods" or "genres". + """ + is_moods = category == "moods" + all_edges: list[ + GetFlowConfigsMeFlowConfigsMoodsEdges | GetFlowConfigsMeFlowConfigsGenresEdges + ] = [] + cursor: str | None = None + while True: + flow_configs = await self.provider.gql_client.get_flow_configs( + moods_first=50 if is_moods else 0, + moods_after=cursor if is_moods else None, + genres_first=0 if is_moods else 50, + genres_after=None if is_moods else cursor, + ) + if not flow_configs or not flow_configs.flow_configs: + break + connection = ( + flow_configs.flow_configs.moods if is_moods else flow_configs.flow_configs.genres + ) + all_edges.extend(connection.edges) + if not connection.page_info.has_next_page: + break + cursor = connection.page_info.end_cursor + return self._flow_configs_to_playlists(all_edges) + + async def _browse_all_flows(self) -> list[Playlist]: + """Fetch all available Deezer flows via search and return as virtual playlists.""" + all_edges: list[SearchFlowsSearchResultsFlowConfigsEdges] = [] + cursor: str | None = None + while True: + result = await self.provider.gql_client.search_flows( + query="flow", first=100, after=cursor + ) + if not result: + break + edges = result.results.flow_configs.edges + all_edges.extend(edges) + if not result.results.flow_configs.page_info.has_next_page: + break + cursor = result.results.flow_configs.page_info.end_cursor + return self._flow_configs_to_playlists(all_edges) + + # -- Explore -- + + async def _browse_explore_root(self, path: str) -> list[MediaItemType | BrowseFolder]: + """Return Explore section: charts, top content, all flows.""" + base = path if path.endswith("/") else path + "/" + charts_cover = None + charts = await self.provider.gql_client.get_charts(tracks_first=1) + if charts and charts.country and charts.country.tracks: + for edge in charts.country.tracks.edges: + if edge.node and edge.node.album and edge.node.album.cover: + if edge.node.album.cover.urls: + charts_cover = edge.node.album.cover.urls[0] + break + return [ + create_virtual_playlist( + self.provider, TOP_CHARTS_PLAYLIST_ID, "Top Charts", image_url=charts_cover + ), + BrowseFolder( + item_id="top_albums", + provider=self.instance_id, + path=f"{base}{BROWSE_TOP_ALBUMS}", + name=BROWSE_TOP_ALBUMS, + ), + BrowseFolder( + item_id="top_artists", + provider=self.instance_id, + path=f"{base}{BROWSE_TOP_ARTISTS}", + name=BROWSE_TOP_ARTISTS, + ), + BrowseFolder( + item_id="top_playlists", + provider=self.instance_id, + path=f"{base}{BROWSE_TOP_PLAYLISTS}", + name=BROWSE_TOP_PLAYLISTS, + ), + BrowseFolder( + item_id="all_flows", + provider=self.instance_id, + path=f"{base}{BROWSE_ALL_FLOWS}", + name=BROWSE_ALL_FLOWS, + ), + ] + + async def _browse_explore_category(self, category: str) -> list[MediaItemType]: + """Fetch items for an Explore sub-category.""" + if category == BROWSE_ALL_FLOWS: + return list(await self._browse_all_flows()) + items: list[MediaItemType] = [] + if category in (BROWSE_TOP_ALBUMS, BROWSE_TOP_ARTISTS, BROWSE_TOP_PLAYLISTS): + charts = await self.provider.gql_client.get_charts(tracks_first=0) + if not charts or not charts.country: + return [] + country = charts.country + if category == BROWSE_TOP_ALBUMS and country.albums: + for album_edge in country.albums.edges: + if album_edge.node is not None: + items.append(parse_album(self.provider, album_edge.node)) + elif category == BROWSE_TOP_ARTISTS and country.artists: + for artist_edge in country.artists.edges: + if artist_edge.node is not None: + items.append(parse_artist(self.provider, artist_edge.node)) + elif category == BROWSE_TOP_PLAYLISTS and country.playlists: + for playlist_edge in country.playlists.edges: + if playlist_edge.node is not None: + items.append(parse_playlist(self.provider, playlist_edge.node)) + return items + + async def _browse_user_charts_category(self, category: str) -> list[MediaItemType]: + """Fetch user chart items (top artists/albums).""" + result = await self.provider.gql_client.get_user_charts() + if not result: + return [] + charts = result.charts + items: list[MediaItemType] = [] + if category == "your_top_artists" and charts.artists: + for artist_edge in charts.artists.edges: + if artist_edge.node is not None: + items.append(parse_artist(self.provider, artist_edge.node)) + elif category == "your_top_albums" and charts.albums: + for album_edge in charts.albums.edges: + if album_edge.node is not None: + items.append(parse_album(self.provider, album_edge.node)) + return items + + # -- Shaker (Music Together) -- + + async def _browse_shaker_root(self, path: str) -> list[BrowseFolder]: + """Return Shaker (Music Together) groups as browse folders.""" + base = path if path.endswith("/") else path + "/" + folders: list[BrowseFolder] = [] + cursor: str | None = None + while True: + result = await self.provider.gql_client.get_music_together_groups( + first=50, after=cursor + ) + if not result: + break + for edge in result.music_together_groups.edges: + if edge.node is None: + continue + group = edge.node + members = group.estimated_members_count + name = f"{group.name} ({members} member{'s' if members != 1 else ''})" + path_name = group.name.replace("/", "-") + self._browse_slug_cache[f"shaker/{path_name}"] = group.id + folders.append( + BrowseFolder( + item_id=f"shaker_{group.id}", + provider=self.instance_id, + path=f"{base}{path_name}", + name=name, + ) + ) + if not result.music_together_groups.page_info.has_next_page: + break + cursor = result.music_together_groups.page_info.end_cursor + return folders + + async def _browse_shaker_group(self, group_id: str) -> list[MediaItemType]: + """Return playlists for a Shaker group: mix + curated tracklist.""" + group = await self.provider.gql_client.get_music_together_group( + group_id=group_id, + mood=MusicTogetherSuggestedTracklistMoodInput.NONE, + tracks_first=1, + ) + if group is None: + return [] + items: list[MediaItemType] = [] + if group.suggested_tracklist and group.suggested_tracklist.tracklist: + items.append( + create_virtual_playlist( + self.provider, + f"{SHAKER_PREFIX}{group_id}", + f"{group.name} - Mix", + image_url=SHAKER_MIX_COVER, + ) + ) + if group.curated_tracklist: + cover_url = ( + group.curated_tracklist.picture.urls[0] + if group.curated_tracklist.picture and group.curated_tracklist.picture.urls + else None + ) + items.append( + create_virtual_playlist( + self.provider, + f"{SHAKER_CURATED_PREFIX}{group_id}", + f"{group.name} - Playlist", + image_url=cover_url, + ) + ) + return items + + # -- Audiobooks -- + + async def _browse_audiobooks_root(self, path: str) -> list[MediaItemType | BrowseFolder]: + """Return audiobook sections from the Deezer audiobooks channel page.""" + page_data = await self.provider.gw_client.get_page(AUDIOBOOKS_CHANNEL) + sections = page_data.get("sections", []) + base = path if path.endswith("/") else path + "/" + items: list[MediaItemType | BrowseFolder] = [] + + for section in sections: + title = section.get("title", "") + if not title: + continue + section_items = section.get("items", []) + if not section_items: + continue + + first_item = section_items[0] + if first_item.get("type") == "channel": + for item in section_items: + data = item.get("data", {}) + target = data.get("target", "") + if not target: + continue + slug = target.removeprefix("/channels/") + channel_name = data.get("name", slug) + path_name = channel_name.replace("/", "-") + self._browse_slug_cache[f"audiobooks/{path_name}"] = f"channels/{slug}" + folder = BrowseFolder( + item_id=f"audiobooks_{slug}", + provider=self.instance_id, + path=f"{base}{path_name}", + name=channel_name, + ) + folder.image = get_gw_item_image(self.provider, item) + items.append(folder) + else: + module_id = section.get("module_id", "") + if not module_id: + continue + path_name = title.replace("/", "-") + self._browse_slug_cache[f"audiobooks/{path_name}"] = f"channels/module/{module_id}" + folder = BrowseFolder( + item_id=f"audiobooks_section_{module_id}", + provider=self.instance_id, + path=f"{base}{path_name}", + name=title, + ) + folder.image = get_gw_item_image(self.provider, first_item) + items.append(folder) + + return items + + async def _browse_audiobooks_page(self, page_path: str) -> list[MediaItemType | BrowseFolder]: + """Fetch a Deezer channel page and return its items.""" + page_data = await self.provider.gw_client.get_page(page_path) + sections = page_data.get("sections", []) + items: list[MediaItemType | BrowseFolder] = [] + for section in sections: + for item in section.get("items", []): + if parsed := parse_gw_item(self.provider, item): + items.append(parsed) + return items + + # -- Recommendations -- + + @use_cache(3600) + async def recommendations(self) -> list[RecommendationFolder]: + """Get Deezer's recommendations including Flow and personalized content.""" + result: list[RecommendationFolder] = [] + recs = await self.provider.gql_client.get_recommendations( + playlists_first=50, + artist_playlists_first=50, + new_releases_first=10, + artists_first=0, + hot_tracks_limit=50, + ) + await self._add_made_for_you(result, recs) + self._add_recommended_playlists(result, recs) + self._add_recommended_artist_playlists(result, recs) + self._add_recommended_tracks(result, recs) + self._add_new_releases(result, recs) + await self._add_flow_configs(result) + recently_played = await self._get_recently_played_items() + if recently_played: + result.append( + RecommendationFolder( + item_id="recently_played", + provider=self.instance_id, + name=BROWSE_RECENTLY_PLAYED, + items=UniqueList(recently_played), + ) + ) + return result + + async def _add_made_for_you( + self, + result: list[RecommendationFolder], + recs: GetRecommendationsMe | None, + ) -> None: + """Add Made For You section to recommendations.""" + made_for_me_items: list[Playlist] = [] + flow_me = await self.provider.gql_client.get_flow() + if flow_me and flow_me.flow: + cover = ( + flow_me.flow.cover.urls[0] + if flow_me.flow.cover and flow_me.flow.cover.urls + else None + ) + made_for_me_items.append( + create_virtual_playlist(self.provider, FLOW_PLAYLIST_ID, "Flow", image_url=cover) + ) + made_for_me_items.extend(await self._get_smart_tracklist_playlists()) + if made_for_me_items: + result.append( + RecommendationFolder( + item_id="made_for_you", + provider=self.instance_id, + name=BROWSE_MADE_FOR_YOU, + items=UniqueList(made_for_me_items), + ) + ) + + def _add_recommended_tracks( + self, + result: list[RecommendationFolder], + recs: GetRecommendationsMe | None, + ) -> None: + """Add Hot Tracks section with tracks rendered directly.""" + if not recs or not recs.recommendations.hot_tracks: + return + track_items = [parse_track(self.provider, ht) for ht in recs.recommendations.hot_tracks] + if track_items: + result.append( + RecommendationFolder( + item_id="recommended_tracks", + provider=self.instance_id, + name="Hot Tracks", + items=UniqueList(track_items), + ) + ) + + def _add_recommended_playlists( + self, + result: list[RecommendationFolder], + recs: GetRecommendationsMe | None, + ) -> None: + """Add Recommended Playlists section (editorial playlists).""" + if not recs or not recs.recommendations: + return + items = [ + parse_playlist(self.provider, edge.node) + for edge in recs.recommendations.playlists.edges + if edge.node is not None + ] + if items: + result.append( + RecommendationFolder( + item_id="recommended_playlists", + provider=self.instance_id, + name=BROWSE_RECOMMENDED_PLAYLISTS, + items=UniqueList(items), + ) + ) + + def _add_recommended_artist_playlists( + self, + result: list[RecommendationFolder], + recs: GetRecommendationsMe | None, + ) -> None: + """Add Recommended Artist Playlists section.""" + if not recs or not recs.recommendations: + return + items = [ + parse_playlist(self.provider, edge.node) + for edge in recs.recommendations.artist_playlists.edges + if edge.node is not None + ] + if items: + result.append( + RecommendationFolder( + item_id="recommended_artist_playlists", + provider=self.instance_id, + name=BROWSE_RECOMMENDED_ARTIST_PLAYLISTS, + items=UniqueList(items), + ) + ) + + def _add_new_releases( + self, + result: list[RecommendationFolder], + recs: GetRecommendationsMe | None, + ) -> None: + """Add New Releases section to recommendations.""" + if recs is None: + return + new_release_items = [ + parse_album(self.provider, edge.node) + for edge in recs.recommendations.new_releases.edges + if edge.node is not None + ] + if new_release_items: + result.append( + RecommendationFolder( + item_id="new_releases", + provider=self.instance_id, + name="New Releases", + items=UniqueList(new_release_items), + ) + ) + + async def _add_flow_configs(self, result: list[RecommendationFolder]) -> None: + """Add Mood and Genre Flow sections to recommendations.""" + flow_configs = await self.provider.gql_client.get_flow_configs( + moods_first=20, genres_first=20 + ) + if not flow_configs or not flow_configs.flow_configs: + return + configs = flow_configs.flow_configs + for folder_id, folder_name, edges in ( + ("mood_flows", "Deezer Mood Flows", configs.moods.edges), + ("genre_flows", "Deezer Genre Flows", configs.genres.edges), + ): + playlists = self._flow_configs_to_playlists(list(edges)) + if playlists: + result.append( + RecommendationFolder( + item_id=folder_id, + provider=self.instance_id, + name=folder_name, + items=UniqueList(playlists), + ) + ) + + # -- Recently played (shared by browse and recommendations) -- + + @use_cache(3600) + async def _get_recently_played_items(self) -> list[MediaItemType]: + """Get recently played items (cached).""" + result = await self.provider.gql_client.get_recently_played(first=50) + if not result: + return [] + return parse_recently_played_edges(self.provider, result.recently_played.edges) + + # -- Virtual playlist metadata -- + + async def get_virtual_playlist(self, prov_playlist_id: str) -> Playlist | None: + """Return a virtual playlist, or None if the ID is not virtual.""" + if prov_playlist_id == FLOW_PLAYLIST_ID: + cover = await self._get_flow_cover() + return create_virtual_playlist(self.provider, FLOW_PLAYLIST_ID, "Flow", image_url=cover) + if prov_playlist_id == RECOMMENDED_TRACKS_PLAYLIST_ID: + return create_virtual_playlist( + self.provider, RECOMMENDED_TRACKS_PLAYLIST_ID, "Hot Tracks" + ) + if prov_playlist_id == TOP_CHARTS_PLAYLIST_ID: + return create_virtual_playlist(self.provider, TOP_CHARTS_PLAYLIST_ID, "Top Charts") + if prov_playlist_id == USER_TOP_TRACKS_PLAYLIST_ID: + return create_virtual_playlist( + self.provider, USER_TOP_TRACKS_PLAYLIST_ID, "Your Top Tracks" + ) + if prov_playlist_id == PERSONAL_SONGS_PLAYLIST_ID: + return create_virtual_playlist(self.provider, PERSONAL_SONGS_PLAYLIST_ID, "My Uploads") + if prov_playlist_id.startswith(FLOW_CONFIG_PREFIX): + config_id = prov_playlist_id.removeprefix(FLOW_CONFIG_PREFIX) + flow_config = await self.provider.gql_client.get_flow_config_tracks( + flow_config_id=config_id + ) + name = f"Flow: {flow_config.title}" if flow_config else f"Flow: {config_id}" + cover = get_flow_config_image(flow_config) if flow_config else None + return create_virtual_playlist(self.provider, prov_playlist_id, name, image_url=cover) + if prov_playlist_id.startswith(SMART_TRACKLIST_PREFIX): + tracklist_id = prov_playlist_id.removeprefix(SMART_TRACKLIST_PREFIX) + tracklist = await self.provider.gql_client.get_smart_tracklist( + smart_tracklist_id=tracklist_id, first=1 + ) + name = tracklist.title if tracklist else f"Mix {tracklist_id}" + cover = ( + tracklist.cover.urls[0] + if tracklist and tracklist.cover and tracklist.cover.urls + else None + ) + return create_virtual_playlist( + self.provider, + prov_playlist_id, + name, + image_url=cover, + ) + if prov_playlist_id.startswith(SHAKER_CURATED_PREFIX): + group_id = prov_playlist_id.removeprefix(SHAKER_CURATED_PREFIX) + group = await self.provider.gql_client.get_music_together_group( + group_id=group_id, + mood=MusicTogetherSuggestedTracklistMoodInput.NONE, + tracks_first=1, + ) + name = f"{group.name} - Playlist" if group else f"Shaker {group_id}" + cover_url: str | None = None + if ( + group + and group.curated_tracklist + and group.curated_tracklist.picture + and group.curated_tracklist.picture.urls + ): + cover_url = group.curated_tracklist.picture.urls[0] + return create_virtual_playlist( + self.provider, + prov_playlist_id, + name, + image_url=cover_url, + ) + if prov_playlist_id.startswith(SHAKER_PREFIX): + group_id = prov_playlist_id.removeprefix(SHAKER_PREFIX) + group = await self.provider.gql_client.get_music_together_group( + group_id=group_id, + mood=MusicTogetherSuggestedTracklistMoodInput.NONE, + tracks_first=1, + ) + name = f"{group.name} - Mix" if group else f"Shaker {group_id}" + return create_virtual_playlist( + self.provider, prov_playlist_id, name, image_url=SHAKER_MIX_COVER + ) + return None + + # -- Virtual playlist track fetchers -- + + async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: + """Get playlist tracks, routing virtual playlist IDs to their fetchers.""" + if page > 0: + return [] + if prov_playlist_id == FLOW_PLAYLIST_ID: + return await self._get_flow_tracks() + if prov_playlist_id == RECOMMENDED_TRACKS_PLAYLIST_ID: + return await self._get_recommended_tracks() + if prov_playlist_id == TOP_CHARTS_PLAYLIST_ID: + return await self._get_chart_tracks() + if prov_playlist_id == USER_TOP_TRACKS_PLAYLIST_ID: + return await self._get_user_chart_tracks() + if prov_playlist_id == PERSONAL_SONGS_PLAYLIST_ID: + return await self._get_personal_songs() + if prov_playlist_id.startswith(FLOW_CONFIG_PREFIX): + return await self._get_flow_config_tracks( + prov_playlist_id.removeprefix(FLOW_CONFIG_PREFIX) + ) + if prov_playlist_id.startswith(SMART_TRACKLIST_PREFIX): + tracklist_id = prov_playlist_id.removeprefix(SMART_TRACKLIST_PREFIX) + return await self._get_smart_tracklist_tracks(tracklist_id) + if prov_playlist_id.startswith(SHAKER_CURATED_PREFIX): + group_id = prov_playlist_id.removeprefix(SHAKER_CURATED_PREFIX) + return await self._get_shaker_curated_tracks(group_id) + if prov_playlist_id.startswith(SHAKER_PREFIX): + shaker_id = prov_playlist_id.removeprefix(SHAKER_PREFIX) + return await self._get_shaker_tracks(shaker_id) + return await self._get_regular_playlist_tracks(prov_playlist_id) + + @use_cache(3600) + async def _get_smart_tracklist_playlists(self) -> list[Playlist]: + """Get SmartTracklist items from Made For Me as virtual playlists.""" + made_for_me = await self.provider.gql_client.get_made_for_me(first=20) + if not made_for_me or not made_for_me.made_for_me: + return [] + playlists: list[Playlist] = [] + for edge in made_for_me.made_for_me.edges: + if edge.node is None: + continue + if isinstance(edge.node, GetMadeForMeMeMadeForMeEdgesNodeSmartTracklist): + cover = ( + edge.node.cover.urls[0] if edge.node.cover and edge.node.cover.urls else None + ) + playlists.append( + create_virtual_playlist( + self.provider, + f"{SMART_TRACKLIST_PREFIX}{edge.node.id}", + edge.node.title, + image_url=cover, + ) + ) + return playlists + + async def _get_flow_tracks(self) -> list[Track]: + """Get a fresh batch of personalized Flow tracks.""" + result = await self.provider.gql_client.get_flow_batch() + if result is None or result.flow is None: + return [] + seen: set[str] = set() + tracks: list[Track] = [] + for batch in ( + result.flow.batch_1, + result.flow.batch_2, + result.flow.batch_3, + result.flow.batch_4, + ): + for ft in batch: + if ft.track is not None and ft.track.id not in seen: + seen.add(ft.track.id) + tracks.append(parse_track(self.provider, ft.track)) + return tracks + + @use_cache(3600) + async def _get_recommended_tracks(self) -> list[Track]: + """Get cached recommended tracks (hot tracks).""" + recs = await self.provider.gql_client.get_recommendations( + playlists_first=0, + artist_playlists_first=0, + new_releases_first=0, + artists_first=0, + hot_tracks_limit=50, + ) + if recs is None or recs.recommendations.hot_tracks is None: + return [] + return [parse_track(self.provider, ht) for ht in recs.recommendations.hot_tracks] + + @use_cache(3600) + async def _get_chart_tracks(self) -> list[Track]: + """Get cached chart tracks.""" + charts = await self.provider.gql_client.get_charts( + country_code=self.provider.gw_client.user_country, + tracks_first=100, + ) + if charts is None or charts.country is None or charts.country.tracks is None: + return [] + return [ + parse_track(self.provider, edge.node) + for edge in charts.country.tracks.edges + if edge.node is not None + ] + + async def _get_flow_config_tracks(self, config_id: str) -> list[Track]: + """ + Get a fresh batch of tracks for a mood/genre Flow config. + + :param config_id: The Flow config identifier (e.g. "happy", "chill", "genre-rock"). + """ + seen: set[str] = set() + tracks: list[Track] = [] + for _ in range(4): + result = await self.provider.gql_client.get_flow_config_tracks(flow_config_id=config_id) + if result is None: + break + for ft in result.tracks: + if ft.track is not None and ft.track.id not in seen: + seen.add(ft.track.id) + tracks.append(parse_track(self.provider, ft.track)) + return tracks + + @use_cache(3600) + async def _get_smart_tracklist_tracks(self, tracklist_id: str) -> list[Track]: + """ + Get tracks for a SmartTracklist. + + :param tracklist_id: The SmartTracklist identifier. + """ + all_tracks: list[Track] = [] + cursor: str | None = None + while True: + result = await self.provider.gql_client.get_smart_tracklist( + smart_tracklist_id=tracklist_id, first=50, after=cursor + ) + if result is None: + break + all_tracks.extend( + parse_track(self.provider, edge.node) + for edge in result.tracks.edges + if edge.node is not None + ) + if not result.tracks.page_info.has_next_page: + break + cursor = result.tracks.page_info.end_cursor + return all_tracks + + async def _get_shaker_tracks(self, group_id: str) -> list[Track]: + """ + Get suggested tracks for a Shaker (Music Together) group. + + :param group_id: The Music Together group identifier. + """ + # Refresh the suggested tracklist to get a fresh set of tracks + await self.provider.gql_client.music_together_refresh_suggested_tracklist( + group_id=group_id, + mood=MusicTogetherRefreshSuggestedTracklistMoodInput.NONE, + ) + group = await self.provider.gql_client.get_music_together_group( + group_id=group_id, + mood=MusicTogetherSuggestedTracklistMoodInput.NONE, + tracks_first=50, + ) + if group is None or group.suggested_tracklist is None: + return [] + tracklist = group.suggested_tracklist.tracklist + if tracklist is None: + return [] + return [ + parse_track(self.provider, edge.node) for edge in tracklist.tracks.edges if edge.node + ] + + async def _get_shaker_curated_tracks(self, group_id: str) -> list[Track]: + """ + Get curated playlist tracks for a Shaker (Music Together) group. + + :param group_id: The Music Together group identifier. + """ + all_tracks: list[Track] = [] + cursor: str | None = None + while True: + group = await self.provider.gql_client.get_music_together_group( + group_id=group_id, + mood=MusicTogetherSuggestedTracklistMoodInput.NONE, + tracks_first=50, + tracks_after=cursor, + ) + if group is None or group.curated_tracklist is None: + break + tracks_conn = group.curated_tracklist.tracks + all_tracks.extend( + parse_track(self.provider, edge.node) for edge in tracks_conn.edges if edge.node + ) + if not tracks_conn.page_info.has_next_page: + break + cursor = tracks_conn.page_info.end_cursor + return all_tracks + + @use_cache(3600) + async def _get_user_chart_tracks(self) -> list[Track]: + """Get the user's most listened tracks.""" + result = await self.provider.gql_client.get_user_charts(tracks_first=50) + if not result or not result.charts.tracks: + return [] + return [ + parse_track(self.provider, edge.node) + for edge in result.charts.tracks.edges + if edge.node is not None + ] + + async def _get_personal_songs(self) -> list[Track]: + """Get user-uploaded personal songs via the GW API.""" + songs = await self.provider.media_manager._get_personal_songs() + return [ + parse_gw_track(self.provider, song, position=idx) for idx, song in enumerate(songs, 1) + ] + + async def invalidate_playlist_cache(self, prov_playlist_id: str) -> None: + """Invalidate the cached playlist tracks after a mutation.""" + cache_key = f"_get_regular_playlist_tracks.{prov_playlist_id}" + await self.mass.cache.delete(key=cache_key, provider=self.instance_id) + + @use_cache(3600 * 3) + async def _get_regular_playlist_tracks(self, prov_playlist_id: str) -> list[Track]: + """Get tracks for regular Deezer playlists (cached).""" + result = await self.provider.gql_client.get_playlist(playlist_id=prov_playlist_id) + if result is None: + return [] + all_edges = list(result.tracks.edges) + while result.tracks.page_info.has_next_page: + result = await self.provider.gql_client.get_playlist( + playlist_id=prov_playlist_id, + tracks_after=result.tracks.page_info.end_cursor, + ) + if result is None: + break + all_edges.extend(result.tracks.edges) + return [ + parse_track(self.provider, edge.node, position=idx) + for idx, edge in enumerate(all_edges, 1) + if edge.node is not None + ] + + @use_cache(3600) + async def _get_flow_cover(self) -> str | None: + """Get the cover URL for the user's Flow.""" + result = await self.provider.gql_client.get_flow() + if result and result.flow and result.flow.cover and result.flow.cover.urls: + return str(result.flow.cover.urls[0]) + return None diff --git a/music_assistant/providers/deezer/constants.py b/music_assistant/providers/deezer/constants.py new file mode 100644 index 0000000000..c6cac3c732 --- /dev/null +++ b/music_assistant/providers/deezer/constants.py @@ -0,0 +1,43 @@ +"""Constants for the Deezer provider.""" + +# -- Virtual playlist IDs -- + +FLOW_PLAYLIST_ID = "flow" +FLOW_CONFIG_PREFIX = "flow_config_" +SMART_TRACKLIST_PREFIX = "smart_tracklist_" +RECOMMENDED_TRACKS_PLAYLIST_ID = "recommended_tracks" +TOP_CHARTS_PLAYLIST_ID = "top_charts" +USER_TOP_TRACKS_PLAYLIST_ID = "user_top_tracks" +SHAKER_PREFIX = "shaker_" +SHAKER_CURATED_PREFIX = "shaker_curated_" +PERSONAL_SONGS_PLAYLIST_ID = "personal_songs" +SHAKER_MIX_COVER = "https://cdn-assets.dzcdn.net/shaker/_next/static/media/group_mix.d986951b.svg" + +# -- Personal item ID prefixes -- + +PERSONAL_ARTIST_PREFIX = "personal_artist_" +PERSONAL_ALBUM_PREFIX = "personal_album_" + +# -- Pagination page sizes -- + +FAVORITES_PAGE_SIZE = 50 +AUDIOBOOK_CHAPTERS_PAGE_SIZE = 200 + +# -- Browse folder names (used as path segments for routing) -- + +BROWSE_MADE_FOR_YOU = "Made For You" +BROWSE_EXPLORE = "Explore" +BROWSE_RECENTLY_PLAYED = "Recently Played" +BROWSE_SHAKER = "Shaker" +BROWSE_AUDIOBOOKS = "Discover Audiobooks" +BROWSE_MOODS = "Moods" +BROWSE_GENRES = "Genres" +BROWSE_YOUR_TOP_ARTISTS = "Your Top Artists" +BROWSE_YOUR_TOP_ALBUMS = "Your Top Albums" +BROWSE_RECOMMENDED_PLAYLISTS = "Recommended Playlists" +BROWSE_RECOMMENDED_ARTIST_PLAYLISTS = "Recommended Artist Playlists" +BROWSE_PERSONALIZED_PLAYLISTS = "Personalized Playlists" +BROWSE_TOP_ALBUMS = "Top Albums" +BROWSE_TOP_ARTISTS = "Top Artists" +BROWSE_TOP_PLAYLISTS = "Top Playlists" +BROWSE_ALL_FLOWS = "All Flows" diff --git a/music_assistant/providers/deezer/gw_client.py b/music_assistant/providers/deezer/gw_client.py index 4eee8ac44c..9018c54765 100644 --- a/music_assistant/providers/deezer/gw_client.py +++ b/music_assistant/providers/deezer/gw_client.py @@ -1,10 +1,10 @@ -"""A minimal client for the unofficial gw-API, which deezer is using on their website and app. +""" +A minimal client for the unofficial gw-API, which deezer is using on their website and app. Credits go out to RemixDev (https://gitlab.com/RemixDev) for figuring out, how to get the arl cookie based on the api_token. """ -import json from collections.abc import Mapping from http.cookies import BaseCookie, Morsel from typing import Any, cast @@ -24,7 +24,7 @@ GW_LIGHT_URL = "https://www.deezer.com/ajax/gw-light.php" -class DeezerGWError(BaseException): +class DeezerGWError(Exception): """Exception type for GWClient related exceptions.""" @@ -32,7 +32,6 @@ class GWClient: """The GWClient class can be used to perform actions not being of the official API.""" _arl_token: str - _api_token: str _gw_csrf_token: str | None _license: str | None _license_expiration_timestamp: int @@ -43,9 +42,8 @@ class GWClient: ] user_country: str - def __init__(self, session: ClientSession, api_token: str, arl_token: str) -> None: - """Provide an aiohttp ClientSession and the deezer api_token.""" - self._api_token = api_token + def __init__(self, session: ClientSession, arl_token: str) -> None: + """Provide an aiohttp ClientSession and the deezer ARL token.""" self._arl_token = arl_token self.session = session @@ -126,47 +124,41 @@ async def _gw_api_call( raise DeezerGWError(msg, result_json["error"]) return cast("dict[str, Any]", result_json) - async def get_user_radio(self, config_id: str) -> list[dict[str, Any]]: - """Get personalized Flow tracks for a specific mood or genre. + # Content support descriptor for page.get — tells the API which module types to return + _PAGE_SUPPORT: dict[str, Any] = { + "grid": ["channel", "album", "playlist", "artist"], + "horizontal-grid": ["channel", "album", "playlist", "artist"], + "slideshow": ["album", "playlist"], + "grid-preview-one": ["album", "playlist"], + "grid-preview-two": ["album", "playlist"], + "filterable-grid": ["album", "playlist"], + "large-card": ["album", "playlist"], + } + + async def get_page(self, page: str, language: str = "en") -> dict[str, Any]: + """ + Fetch a content page from the Deezer page.get GW API. - :param config_id: The Flow config identifier (e.g. "happy", "chill", "genre-rock"). + :param page: The page path (e.g., 'channels/audiobooks'). + :param language: Language code for localized content. """ - result = await self._gw_api_call( - "radio.getUserRadio", - args={"config_id": config_id, "user_id": self._user_id}, - ) - if "data" not in result["results"]: - return [] - return cast("list[dict[str, Any]]", result["results"]["data"]) - - async def get_home_flows(self) -> list[dict[str, Any]]: - """Discover available Flow variants from the Deezer home page.""" - gateway_input = json.dumps( - { - "PAGE": "home", - "VERSION": "2.5", - "SUPPORT": {"filterable-grid": ["flow"]}, - } - ) result = await self._gw_api_call( "page.get", - params={"gateway_input": gateway_input}, + args={ + "PAGE": page, + "VERSION": "2.5", + "SUPPORT": self._PAGE_SUPPORT, + "LANG": language, + "OPTIONS": [], + }, ) - sections = result["results"].get("sections", []) - for section in sections: - if section.get("layout") == "filterable-grid": - return cast("list[dict[str, Any]]", section["items"]) - return [] - - async def get_song_data(self, track_id: str) -> dict[str, Any]: - """Get data such as the track token for a given track.""" - return await self._gw_api_call("song.getData", args={"SNG_ID": track_id}) + return cast("dict[str, Any]", result["results"]) async def get_deezer_track_urls(self, track_id: str) -> tuple[dict[str, Any], dict[str, Any]]: """Get the URL for a given track id.""" dz_license = await self._get_license() - song_results = await self.get_song_data(track_id) + song_results = await self._gw_api_call("song.getData", args={"SNG_ID": track_id}) song_data = song_results["results"] # If the song has been replaced by a newer version, the old track will @@ -177,12 +169,17 @@ async def get_deezer_track_urls(self, track_id: str) -> tuple[dict[str, Any], di song_data = song_data["FALLBACK"] track_token = song_data["TRACK_TOKEN"] + # Personal songs (user uploads) only support MP3_MISC format + is_personal = int(track_id) < 0 + formats = ( + [{"cipher": "BF_CBC_STRIPE", "format": "MP3_MISC"}] if is_personal else self.formats + ) url_data = { "license_token": dz_license, "media": [ { "type": "FULL", - "formats": self.formats, + "formats": formats, } ], "track_tokens": [track_token], @@ -195,7 +192,11 @@ async def get_deezer_track_urls(self, track_id: str) -> tuple[dict[str, Any], di result_json = await url_response.json() if error := result_json["data"][0].get("errors"): - msg = "Received an error from API" + error_code = error[0].get("code") if isinstance(error, list) and error else None + if error_code == 2002: + msg = f"Track {track_id} not available: insufficient streaming rights" + else: + msg = "Received an error from API" raise DeezerGWError(msg, error) media_list = result_json["data"][0].get("media", []) @@ -218,9 +219,11 @@ async def log_listen( payload["next_media"] = {"media": {"id": next_track, "type": "song"}} if last_track: - seconds_streamed = min( - utc_timestamp() - last_track.data["start_ts"], - last_track.seconds_streamed, + elapsed = utc_timestamp() - last_track.data["start_ts"] + seconds_streamed = ( + min(elapsed, last_track.seconds_streamed) + if last_track.seconds_streamed is not None + else elapsed ) payload["params"] = { @@ -246,3 +249,16 @@ async def log_listen( } await self._gw_api_call("log.listen", args=payload) + + async def get_personal_songs(self, start: int = 0, nb: int = 500) -> dict[str, Any]: + """ + Get user-uploaded personal songs via the GW API. + + :param start: Offset for pagination. + :param nb: Number of songs to fetch per page. + """ + result = await self._gw_api_call( + "personal_song.getList", + args={"start": start, "nb": nb}, + ) + return cast("dict[str, Any]", result["results"]) diff --git a/music_assistant/providers/deezer/helpers.py b/music_assistant/providers/deezer/helpers.py new file mode 100644 index 0000000000..0374d65212 --- /dev/null +++ b/music_assistant/providers/deezer/helpers.py @@ -0,0 +1,195 @@ +""" +Shared helper functions for the Deezer provider. + +Utility functions used across multiple modules (parsers, browse, media, streaming). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ImageType, MediaType +from music_assistant_models.media_items import ( + MediaItemImage, + MediaItemMetadata, + Playlist, + ProviderMapping, + UniqueList, +) + +from .constants import ( + AUDIOBOOK_CHAPTERS_PAGE_SIZE, + FLOW_CONFIG_PREFIX, + FLOW_PLAYLIST_ID, + PERSONAL_SONGS_PLAYLIST_ID, + RECOMMENDED_TRACKS_PLAYLIST_ID, + SHAKER_CURATED_PREFIX, + SHAKER_PREFIX, + SMART_TRACKLIST_PREFIX, + TOP_CHARTS_PLAYLIST_ID, + USER_TOP_TRACKS_PLAYLIST_ID, +) + +if TYPE_CHECKING: + from deezer_python_gql import DeezerGQLClient + from deezer_python_gql.generated.get_audiobook import ( + GetAudiobookAudiobookChaptersEdges, + GetAudiobookAudiobookChaptersPageInfo, + ) + + from .provider import DeezerProvider + + +@dataclass(frozen=True) +class VirtualPlaylistMeta: + """Canonical metadata for a virtual playlist type.""" + + name: str + is_dynamic: bool = False + + +# Registry of virtual playlist types with their canonical name and is_dynamic flag. +# Keyed by exact playlist ID for fixed IDs, and by prefix for parameterized IDs. +VIRTUAL_PLAYLIST_TYPES: dict[str, VirtualPlaylistMeta] = { + FLOW_PLAYLIST_ID: VirtualPlaylistMeta("Flow", is_dynamic=True), + FLOW_CONFIG_PREFIX: VirtualPlaylistMeta("Flow", is_dynamic=True), + SMART_TRACKLIST_PREFIX: VirtualPlaylistMeta("Mix"), + RECOMMENDED_TRACKS_PLAYLIST_ID: VirtualPlaylistMeta("Hot Tracks"), + TOP_CHARTS_PLAYLIST_ID: VirtualPlaylistMeta("Top Charts"), + USER_TOP_TRACKS_PLAYLIST_ID: VirtualPlaylistMeta("Your Top Tracks"), + PERSONAL_SONGS_PLAYLIST_ID: VirtualPlaylistMeta("My Uploads"), + SHAKER_PREFIX: VirtualPlaylistMeta("Mix", is_dynamic=True), + SHAKER_CURATED_PREFIX: VirtualPlaylistMeta("Playlist"), +} + + +def get_virtual_playlist_meta(item_id: str) -> VirtualPlaylistMeta | None: + """ + Look up canonical metadata for a virtual playlist by its item_id. + + Tries exact match first, then longest prefix match. + """ + if item_id in VIRTUAL_PLAYLIST_TYPES: + return VIRTUAL_PLAYLIST_TYPES[item_id] + # Sort by prefix length descending so longer prefixes match first + # (e.g. "shaker_curated_" before "shaker_") + for prefix, meta in sorted( + VIRTUAL_PLAYLIST_TYPES.items(), key=lambda x: len(x[0]), reverse=True + ): + if prefix.endswith("_") and item_id.startswith(prefix): + return meta + return None + + +def create_virtual_playlist( + provider: DeezerProvider, + item_id: str, + name: str, + image_url: str | None = None, + is_dynamic: bool | None = None, +) -> Playlist: + """ + Create a virtual playlist for Flow, recommended content, etc. + + :param provider: The Deezer provider instance. + :param item_id: The unique identifier (e.g., "flow", "smart_tracklist_123"). + :param name: Display name for the playlist. + :param image_url: Optional cover image URL. + :param is_dynamic: Whether the playlist returns fresh tracks on each fetch. + If None, the value is looked up from the virtual playlist registry. + """ + if is_dynamic is None: + meta = get_virtual_playlist_meta(item_id) + is_dynamic = meta.is_dynamic if meta else False + images: UniqueList[MediaItemImage] = UniqueList() + if image_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + return Playlist( + item_id=item_id, + provider=provider.instance_id, + name=name, + media_type=MediaType.PLAYLIST, + provider_mappings={ + ProviderMapping( + item_id=item_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + metadata=MediaItemMetadata(images=images) if images else MediaItemMetadata(), + is_editable=False, + is_dynamic=is_dynamic, + owner="Deezer", + ) + + +async def fetch_all_audiobook_chapter_edges( + gql_client: DeezerGQLClient, + audiobook_id: str, + page_size: int = AUDIOBOOK_CHAPTERS_PAGE_SIZE, + *, + initial_edges: list[GetAudiobookAudiobookChaptersEdges] | None = None, + initial_page_info: GetAudiobookAudiobookChaptersPageInfo | None = None, +) -> list[GetAudiobookAudiobookChaptersEdges]: + """ + Paginate through all chapters of an audiobook and return the full edge list. + + :param gql_client: The Deezer GQL client to use. + :param audiobook_id: The audiobook ID to fetch chapters for. + :param page_size: Number of chapters per page. + :param initial_edges: Pre-fetched edges to avoid re-fetching the first page. + :param initial_page_info: Page info from the pre-fetched result. + """ + if initial_edges is not None and initial_page_info is not None: + all_edges = list(initial_edges) + page_info = initial_page_info + else: + result = await gql_client.get_audiobook(audiobook_id=audiobook_id, chapters_first=page_size) + if result is None: + return [] + all_edges = list(result.chapters.edges) + page_info = result.chapters.page_info + while page_info.has_next_page: + next_page = await gql_client.get_audiobook( + audiobook_id=audiobook_id, + chapters_first=page_size, + chapters_after=page_info.end_cursor, + ) + if next_page is None: + break + all_edges.extend(next_page.chapters.edges) + page_info = next_page.chapters.page_info + return all_edges + + +async def fetch_all_bookmarks(gql_client: DeezerGQLClient) -> dict[str, tuple[bool, int]]: + """ + Paginate through all podcast episode bookmarks and return a lookup dict. + + :param gql_client: The Deezer GQL client to use. + :returns: Dict mapping episode ID to (is_played, position_ms). + """ + bookmarks: dict[str, tuple[bool, int]] = {} + cursor: str | None = None + while True: + result = await gql_client.get_podcast_episode_bookmarks(first=50, after=cursor) + if not result: + break + for edge in result.podcast_episode_bookmarks.edges: + if edge.node is not None: + bookmarks[edge.node.episode.id] = ( + edge.node.is_played, + edge.node.position * 1000, + ) + if not result.podcast_episode_bookmarks.page_info.has_next_page: + break + cursor = result.podcast_episode_bookmarks.page_info.end_cursor + return bookmarks diff --git a/music_assistant/providers/deezer/manifest.json b/music_assistant/providers/deezer/manifest.json index 667567fd71..0b4c9576ef 100644 --- a/music_assistant/providers/deezer/manifest.json +++ b/music_assistant/providers/deezer/manifest.json @@ -4,9 +4,11 @@ "stage": "stable", "name": "Deezer", "description": "Stream Deezer’s full music catalogue in CD-quality (FLAC) audio.", - "codeowners": ["@arctixdev", "@micha91"], - "credits": ["[deezer-python-async](https://github.com/music-assistant/deezer-python-async)"], + "codeowners": ["@arctixdev", "@micha91", "@jdaberkow"], + "credits": [ + "[deezer-python-gql](https://github.com/music-assistant/deezer-python-gql)" + ], "documentation": "https://music-assistant.io/music-providers/deezer/", - "requirements": ["deezer-python-async==0.3.0", "pycryptodome==3.23.0"], + "requirements": ["deezer-python-gql==0.17.0", "pycryptodome==3.23.0"], "multi_instance": true } diff --git a/music_assistant/providers/deezer/media.py b/music_assistant/providers/deezer/media.py new file mode 100644 index 0000000000..663b5fe09f --- /dev/null +++ b/music_assistant/providers/deezer/media.py @@ -0,0 +1,808 @@ +""" +Media operations manager for the Deezer provider. + +Handles library retrieval, search, item getters, content getters, +library mutations, and playlist CRUD operations. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, Protocol + +from deezer_python_gql import GraphQLClientGraphQLMultiError +from music_assistant_models.enums import MediaType +from music_assistant_models.errors import MediaNotFoundError, UnsupportedFeaturedException +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + ItemMapping, + MediaItemType, + Playlist, + Podcast, + PodcastEpisode, + ProviderMapping, + Radio, + SearchResults, + Track, + UniqueList, +) + +from music_assistant.controllers.cache import use_cache + +from .constants import ( + AUDIOBOOK_CHAPTERS_PAGE_SIZE, + FAVORITES_PAGE_SIZE, + PERSONAL_ALBUM_PREFIX, + PERSONAL_ARTIST_PREFIX, +) +from .helpers import fetch_all_audiobook_chapter_edges, fetch_all_bookmarks +from .parsers import ( + apply_web_url, + parse_album, + parse_artist, + parse_audiobook, + parse_audiobook_chapters, + parse_audiobook_from_album, + parse_date, + parse_gw_track, + parse_playlist, + parse_podcast, + parse_podcast_episode, + parse_radio, + parse_track, +) + +if TYPE_CHECKING: + from .provider import DeezerProvider + + +# -- Protocols for typed pagination -- + + +class _PageInfo(Protocol): + @property + def has_next_page(self) -> bool: ... + + @property + def end_cursor(self) -> str | None: ... + + +class _Connection(Protocol): + @property + def edges(self) -> list[Any]: ... + + @property + def page_info(self) -> _PageInfo: ... + + +def _is_complexity_error(err: GraphQLClientGraphQLMultiError) -> bool: + """Check if a GraphQL error is a query complexity limit violation.""" + return any("complexity" in e.message.lower() for e in err.errors) + + +class DeezerMediaManager: + """Handles library sync, search, item getters, and mutations.""" + + def __init__(self, provider: DeezerProvider) -> None: + """Initialize media manager.""" + self.provider = provider + self.mass = provider.mass + self.instance_id = provider.instance_id + self.domain = provider.domain + self.logger = provider.logger + self._audiobook_ids_in_favorites: set[str] | None = None + + # -- Pagination helper -- + + async def _iter_paged( + self, + fetch: Callable[..., Awaitable[Any]], + extract: Callable[..., _Connection | None], + ) -> AsyncGenerator[Any, None]: + """Iterate a cursor-paginated connection, yielding edges with non-null nodes.""" + cursor: str | None = None + while True: + result = await fetch(first=FAVORITES_PAGE_SIZE, after=cursor) + if result is None: + break + connection = extract(result) + if connection is None: + break + for edge in connection.edges: + if edge.node is not None: + yield edge + if not connection.page_info.has_next_page: + break + cursor = connection.page_info.end_cursor + + # -- Personal songs cache -- + + @use_cache(3600 * 24) + async def _get_personal_songs(self) -> list[dict[str, Any]]: + """Fetch all user-uploaded personal songs via the GW API (cached 24h).""" + all_songs: list[dict[str, Any]] = [] + start = 0 + page_size = 500 + while True: + results = await self.provider.gw_client.get_personal_songs(start=start, nb=page_size) + data: list[dict[str, Any]] = results.get("data", []) + all_songs.extend(data) + if len(data) < page_size: + break + start += page_size + return all_songs + + # -- Library retrieval -- + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve all library artists from Deezer.""" + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_artists, + lambda r: r.user_favorites.artists, + ): + item = parse_artist(self.provider, edge.node) + if edge.favorited_at: + item.date_added = parse_date(edge.favorited_at) + yield item + # Also include artists from user-uploaded personal songs + personal_songs = await self._get_personal_songs() + seen_artist_names: set[str] = set() + for song in personal_songs: + track = parse_gw_track(self.provider, song) + for artist in track.artists: + if isinstance(artist, Artist) and artist.name not in seen_artist_names: + seen_artist_names.add(artist.name) + yield artist + + async def _get_audiobook_ids_in_albums(self) -> set[str]: + """Identify which favorite album IDs are actually audiobooks.""" + # Deezer stores audiobook favorites in the albums list, not in the + # dedicated (deprecated) audiobook favorites endpoint. We use + # check_audiobook_ids to tell them apart. Result is cached for the + # lifetime of this manager instance so both get_library_albums and + # get_library_audiobooks can share it without extra API calls. + if self._audiobook_ids_in_favorites is not None: + return self._audiobook_ids_in_favorites + album_ids: list[str] = [] + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_albums, + lambda r: r.user_favorites.albums, + ): + album_ids.append(edge.node.id) + if not album_ids: + self._audiobook_ids_in_favorites = set() + else: + self._audiobook_ids_in_favorites = await self.provider.gql_client.check_audiobook_ids( + album_ids + ) + return self._audiobook_ids_in_favorites + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve all library albums from Deezer.""" + # Collect all favorite album edges in a single pass, then determine + # which are audiobooks via check_audiobook_ids, and yield the rest. + all_edges: list[Any] = [] + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_albums, + lambda r: r.user_favorites.albums, + ): + all_edges.append(edge) + # Populate the favorites-audiobook cache (shared with get_library_audiobooks) + if self._audiobook_ids_in_favorites is None: + album_ids = [edge.node.id for edge in all_edges] + self._audiobook_ids_in_favorites = ( + await self.provider.gql_client.check_audiobook_ids(album_ids) + if album_ids + else set() + ) + for edge in all_edges: + if edge.node.id in self._audiobook_ids_in_favorites: + continue + item = parse_album(self.provider, edge.node) + if edge.favorited_at: + item.date_added = parse_date(edge.favorited_at) + yield item + # Also include albums from user-uploaded personal songs + personal_songs = await self._get_personal_songs() + seen_album_names: set[str] = set() + for song in personal_songs: + track = parse_gw_track(self.provider, song) + if isinstance(track.album, Album) and track.album.name not in seen_album_names: + seen_album_names.add(track.album.name) + yield track.album + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve all library playlists from Deezer.""" + # User-owned playlists first + seen_ids: set[str] = set() + async for edge in self._iter_paged( + self.provider.gql_client.get_user_playlists, + lambda r: r.playlists, + ): + seen_ids.add(edge.node.id) + yield parse_playlist(self.provider, edge.node, is_editable=True) + # Favorited playlists (other users' playlists) + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_playlists, + lambda r: r.user_favorites.playlists, + ): + if edge.node.id in seen_ids: + continue + item = parse_playlist(self.provider, edge.node) + if edge.favorited_at: + item.date_added = parse_date(edge.favorited_at) + yield item + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve all library tracks from Deezer (favorites + personal uploads).""" + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_tracks, + lambda r: r.user_favorites.tracks, + ): + item = parse_track(self.provider, edge.node) + if edge.favorited_at: + item.date_added = parse_date(edge.favorited_at) + yield item + # Also include user-uploaded personal songs + personal_songs = await self._get_personal_songs() + for idx, song in enumerate(personal_songs, 1): + yield parse_gw_track(self.provider, song, position=idx) + + async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: + """Retrieve library/subscribed podcasts from Deezer.""" + async for edge in self._iter_paged( + self.provider.gql_client.get_favorite_podcasts, + lambda r: r.user_favorites.podcasts, + ): + item = parse_podcast(self.provider, edge.node) + if edge.favorited_at: + item.date_added = parse_date(edge.favorited_at) + yield item + + async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: + """ + Retrieve library/subscribed audiobooks from Deezer. + + Checks both the dedicated (deprecated) audiobook favorites endpoint + and the regular favorite albums list, since Deezer stores audiobook + favorites in the albums list. + """ + seen_ids: set[str] = set() + # 1. Dedicated audiobook favorites (deprecated but may still have entries) + result = await self.provider.gql_client.get_favorite_audiobooks() + if result is not None and result.favorites.raw_audiobooks is not None: + for raw in result.favorites.raw_audiobooks: + try: + item = await self.get_audiobook(raw.id) + except MediaNotFoundError: + continue + seen_ids.add(raw.id) + if raw.favorited_at: + item.date_added = parse_date(raw.favorited_at) + yield item + # 2. Audiobooks stored as favorite albums + audiobook_ids = await self._get_audiobook_ids_in_albums() + for ab_id in audiobook_ids: + if ab_id in seen_ids: + continue + try: + yield await self.get_audiobook(ab_id) + except MediaNotFoundError: + continue + + # -- Search -- + + @use_cache(60 * 15) + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 5 + ) -> SearchResults: + """Perform search on music provider.""" + self.logger.debug("search called with media_types=%s", media_types) + need_albums = MediaType.ALBUM in media_types + need_audiobooks = MediaType.AUDIOBOOK in media_types + + # Try with full limit first; on complexity error, retry with reduced limits. + attempts = [limit, max(limit // 2, 5), 5] + result = None + for idx, attempt_limit in enumerate(attempts): + try: + result = await self.provider.gql_client.search( + query=search_query, + tracks_first=attempt_limit if MediaType.TRACK in media_types else 0, + albums_first=attempt_limit if (need_albums or need_audiobooks) else 0, + artists_first=attempt_limit if MediaType.ARTIST in media_types else 0, + playlists_first=attempt_limit if MediaType.PLAYLIST in media_types else 0, + livestreams_first=attempt_limit if MediaType.RADIO in media_types else 0, + podcasts_first=attempt_limit if MediaType.PODCAST in media_types else 0, + ) + break + except GraphQLClientGraphQLMultiError as err: + if not _is_complexity_error(err): + raise + if idx == len(attempts) - 1: + self.logger.warning("Search complexity exceeded even at minimum limit") + raise + self.logger.debug( + "Search complexity exceeded at limit=%d, retrying with %d", + attempt_limit, + attempts[idx + 1], + ) + search_results = SearchResults() + if result is None: + return search_results + if MediaType.TRACK in media_types: + search_results.tracks = [ + parse_track(self.provider, edge.node) + for edge in result.results.tracks.edges + if edge.node is not None + ] + if need_albums or need_audiobooks: + album_nodes = [e.node for e in result.results.albums.edges if e.node is not None] + if album_nodes and need_audiobooks: + album_ids = [n.id for n in album_nodes] + audiobook_ids = await self.provider.gql_client.check_audiobook_ids(album_ids) + if need_albums: + search_results.albums = [ + parse_album(self.provider, n) + for n in album_nodes + if n.id not in audiobook_ids + ] + search_results.audiobooks = [ + parse_audiobook_from_album(self.provider, n) + for n in album_nodes + if n.id in audiobook_ids + ] + elif need_albums: + search_results.albums = [parse_album(self.provider, n) for n in album_nodes] + if MediaType.ARTIST in media_types: + search_results.artists = [ + parse_artist(self.provider, edge.node) + for edge in result.results.artists.edges + if edge.node is not None + ] + if MediaType.PLAYLIST in media_types: + search_results.playlists = [ + parse_playlist(self.provider, edge.node) + for edge in result.results.playlists.edges + if edge.node is not None + ] + if MediaType.RADIO in media_types: + search_results.radio = [ + parse_radio(self.provider, edge.node) + for edge in result.results.livestreams.edges + if edge.node is not None + ] + if MediaType.PODCAST in media_types: + search_results.podcasts = [ + parse_podcast(self.provider, edge.node) + for edge in result.results.podcasts.edges + if edge.node is not None + ] + return search_results + + # -- Item getters -- + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + if prov_artist_id.startswith(PERSONAL_ARTIST_PREFIX): + # Personal track artist — reconstruct from GW data + song_id = prov_artist_id.removeprefix(PERSONAL_ARTIST_PREFIX) + personal_songs = await self._get_personal_songs() + for song in personal_songs: + if str(song["SNG_ID"]) == song_id: + return Artist( + item_id=prov_artist_id, + provider=self.instance_id, + name=song.get("ART_NAME", ""), + provider_mappings={ + ProviderMapping( + item_id=prov_artist_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ) + raise MediaNotFoundError(f"Personal artist {prov_artist_id} not found") + result = await self.provider.gql_client.get_artist(artist_id=prov_artist_id) + if result is None: + raise MediaNotFoundError(f"Artist {prov_artist_id} not found on Deezer") + item = parse_artist(self.provider, result) + apply_web_url(item, result) + return item + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + if prov_album_id.startswith(PERSONAL_ALBUM_PREFIX): + # Personal track album — reconstruct from GW data + song_id = prov_album_id.removeprefix(PERSONAL_ALBUM_PREFIX) + personal_songs = await self._get_personal_songs() + for song in personal_songs: + if str(song["SNG_ID"]) == song_id: + art_name = song.get("ART_NAME", "") + personal_art_id = f"{PERSONAL_ARTIST_PREFIX}{song_id}" + artists: UniqueList[Artist | ItemMapping] = UniqueList() + if art_name: + artists.append( + ItemMapping( + media_type=MediaType.ARTIST, + item_id=personal_art_id, + provider=self.instance_id, + name=art_name, + ) + ) + return Album( + item_id=prov_album_id, + provider=self.instance_id, + name=song.get("ALB_TITLE", ""), + artists=artists, + provider_mappings={ + ProviderMapping( + item_id=prov_album_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ) + raise MediaNotFoundError(f"Personal album {prov_album_id} not found") + result = await self.provider.gql_client.get_album(album_id=prov_album_id) + if result is None: + raise MediaNotFoundError(f"Album {prov_album_id} not found on Deezer") + item = parse_album(self.provider, result) + apply_web_url(item, result) + return item + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + try: + track_id_int = int(prov_track_id) + except ValueError as err: + raise MediaNotFoundError(f"Invalid Deezer track ID: {prov_track_id}") from err + # Personal tracks (negative IDs) don't exist in the GQL API + if track_id_int < 0: + personal_songs = await self._get_personal_songs() + for song in personal_songs: + if str(song["SNG_ID"]) == prov_track_id: + return parse_gw_track(self.provider, song) + raise MediaNotFoundError(f"Personal track {prov_track_id} not found") + result = await self.provider.gql_client.get_track(track_id=prov_track_id) + if result is None: + raise MediaNotFoundError(f"Track {prov_track_id} not found on Deezer") + return parse_track(self.provider, result) + + @use_cache(3600 * 24 * 30) + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + if virtual := await self.provider.browse_manager.get_virtual_playlist(prov_playlist_id): + return virtual + result = await self.provider.gql_client.get_playlist(playlist_id=prov_playlist_id) + if result is None: + raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found on Deezer") + is_editable = result.owner is not None and result.owner.id == self.provider.user_id + return parse_playlist(self.provider, result, is_editable=is_editable) + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio/livestream details by id.""" + result = await self.provider.gql_client.get_livestream(livestream_id=prov_radio_id) + if result is None: + raise MediaNotFoundError(f"Radio {prov_radio_id} not found on Deezer") + return parse_radio(self.provider, result) + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get full podcast details by id.""" + result = await self.provider.gql_client.get_podcast(podcast_id=prov_podcast_id) + if result is None: + raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found on Deezer") + podcast = parse_podcast(self.provider, result) + podcast.total_episodes = len(result.raw_episodes) + return podcast + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Get (full) podcast episode details by id.""" + result = await self.provider.gql_client.get_podcast_episode( + podcast_episode_id=prov_episode_id, + ) + if result is None: + raise MediaNotFoundError(f"Podcast episode {prov_episode_id} not found on Deezer") + podcast_mapping = ItemMapping( + media_type=MediaType.PODCAST, + item_id=result.podcast.id, + provider=self.instance_id, + name=result.podcast.display_title, + ) + podcast_image_url = ( + result.podcast.cover.urls[0] + if result.podcast.cover and result.podcast.cover.urls + else None + ) + return parse_podcast_episode(self.provider, result, podcast_mapping, 0, podcast_image_url) + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: + """Get full audiobook details by id.""" + result = await self.provider.gql_client.get_audiobook( + audiobook_id=prov_audiobook_id, chapters_first=AUDIOBOOK_CHAPTERS_PAGE_SIZE + ) + if result is None: + raise MediaNotFoundError(f"Audiobook {prov_audiobook_id} not found on Deezer") + item = parse_audiobook(self.provider, result) + if result.chapters.page_info.has_next_page: + all_edges = await fetch_all_audiobook_chapter_edges( + self.provider.gql_client, + prov_audiobook_id, + initial_edges=result.chapters.edges, + initial_page_info=result.chapters.page_info, + ) + else: + all_edges = result.chapters.edges + item.metadata.chapters = parse_audiobook_chapters(all_edges) + return item + + # -- Content getters -- + + @use_cache(3600 * 24 * 30, allow_expired_cache=True) + async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + """Get all tracks in an album.""" + if prov_album_id.startswith(PERSONAL_ALBUM_PREFIX): + # Personal album has no real Deezer album page + return [] + result = await self.provider.gql_client.get_album(album_id=prov_album_id) + if result is None: + return [] + all_edges = list(result.tracks.edges) + while result.tracks.page_info.has_next_page: + result = await self.provider.gql_client.get_album( + album_id=prov_album_id, + tracks_after=result.tracks.page_info.end_cursor, + ) + if result is None: + break + all_edges.extend(result.tracks.edges) + return [ + parse_track(self.provider, edge.node, position=idx) + for idx, edge in enumerate(all_edges, 1) + if edge.node is not None + ] + + async def get_podcast_episodes( + self, prov_podcast_id: str + ) -> AsyncGenerator[PodcastEpisode, None]: + """Get all episodes for a given podcast with current resume state.""" + episodes = await self._fetch_podcast_episodes(prov_podcast_id) + if not episodes: + return + bookmarks = await fetch_all_bookmarks(self.provider.gql_client) + for ep in episodes: + ep.fully_played = False + ep.resume_position_ms = 0 + if ep.item_id in bookmarks: + ep.fully_played, ep.resume_position_ms = bookmarks[ep.item_id] + yield ep + + @use_cache(3600) + async def _fetch_podcast_episodes(self, prov_podcast_id: str) -> list[PodcastEpisode]: + """Fetch all episodes for a podcast (cached 1h).""" + # Two-layer caching strategy: + # - Outer (this decorator, 1h): avoids repeated cache lookups during + # rapid navigation (e.g., user browsing back and forth between podcasts). + # - Inner (per-episode, 30 days): prevents re-fetching episode details + # that rarely change. When the outer cache expires, only genuinely new + # episodes require an API call. + result = await self.provider.gql_client.get_podcast( + podcast_id=prov_podcast_id, episodes_first=0 + ) + if result is None: + return [] + podcast_mapping = ItemMapping( + media_type=MediaType.PODCAST, + item_id=result.id, + provider=self.instance_id, + name=result.display_title, + ) + podcast_image_url: str | None = None + if result.cover and result.cover.urls: + podcast_image_url = result.cover.urls[0] + episode_ids = result.raw_episodes + if not episode_ids: + return [] + + cache = self.mass.cache + episode_cache_ttl = 3600 * 24 * 30 # 30 days + + # Resolve cached vs uncached episode IDs + cached_episodes: dict[str, PodcastEpisode] = {} + uncached_ids: list[str] = [] + for eid in episode_ids: + cache_key = f"podcast_episode.{eid}" + cached = await cache.get(cache_key, provider=self.instance_id) + if cached is not None: + cached_episodes[eid] = PodcastEpisode.from_dict(cached) + else: + uncached_ids.append(eid) + + # Batch-fetch only uncached episodes + batch_size = 50 + for i in range(0, len(uncached_ids), batch_size): + batch = uncached_ids[i : i + batch_size] + fetched = await self.provider.gql_client.get_podcast_episodes_by_ids(ids=batch) + for ep in fetched: + if ep is not None: + parsed = parse_podcast_episode( + self.provider, ep, podcast_mapping, 0, podcast_image_url + ) + cached_episodes[ep.id] = parsed + self.mass.create_task( + cache.set( + key=f"podcast_episode.{ep.id}", + data=parsed.to_dict(), + expiration=episode_cache_ttl, + provider=self.instance_id, + ) + ) + + # Build final list in original order with correct positions + episodes: list[PodcastEpisode] = [] + position = 0 + for eid in episode_ids: + if eid in cached_episodes: + position += 1 + cached_ep = cached_episodes[eid] + cached_ep.position = position + episodes.append(cached_ep) + return episodes + + @use_cache(3600 * 24 * 7, allow_expired_cache=True) + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + """Get albums by an artist.""" + if prov_artist_id.startswith(PERSONAL_ARTIST_PREFIX): + # Personal artist has no real Deezer artist page + return [] + result = await self.provider.gql_client.get_artist(artist_id=prov_artist_id) + if result is None: + return [] + all_edges = list(result.albums.edges) + while result.albums.page_info.has_next_page: + result = await self.provider.gql_client.get_artist( + artist_id=prov_artist_id, + albums_after=result.albums.page_info.end_cursor, + ) + if result is None: + break + all_edges.extend(result.albums.edges) + return [ + parse_album(self.provider, edge.node) for edge in all_edges if edge.node is not None + ] + + @use_cache(3600 * 24 * 7, allow_expired_cache=True) + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Get top tracks of an artist.""" + if prov_artist_id.startswith(PERSONAL_ARTIST_PREFIX): + # Personal artist has no real Deezer artist page + return [] + result = await self.provider.gql_client.get_artist(artist_id=prov_artist_id) + if result is None or result.top_tracks is None: + return [] + all_edges = list(result.top_tracks.edges) + while result.top_tracks is not None and result.top_tracks.page_info.has_next_page: + result = await self.provider.gql_client.get_artist( + artist_id=prov_artist_id, + top_tracks_after=result.top_tracks.page_info.end_cursor, + ) + if result is None or result.top_tracks is None: + break + all_edges.extend(result.top_tracks.edges) + return [ + parse_track(self.provider, edge.node) for edge in all_edges if edge.node is not None + ] + + @use_cache(3600 * 24, allow_expired_cache=True) + async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: + """Retrieve a dynamic list of tracks based on the provided item.""" + result = await self.provider.gql_client.get_similar_tracks(track_id=prov_track_id, nb=limit) + if result is None: + return [] + return [parse_track(self.provider, t) for t in result.recommended_tracks if t is not None] + + @use_cache(3600 * 24, allow_expired_cache=True) + async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]: + """Retrieve a list of artists similar to the provided artist.""" + if prov_artist_id.startswith(PERSONAL_ARTIST_PREFIX): + return [] + result = await self.provider.gql_client.get_similar_artists( + artist_id=prov_artist_id, first=limit + ) + if result is None or result.related_artist is None: + return [] + return [ + parse_artist(self.provider, edge.node) + for edge in result.related_artist.edges + if edge.node is not None + ] + + # -- Library mutations -- + + async def library_add(self, item: MediaItemType) -> bool: + """Add an item to the provider's library/favorites.""" + if item.media_type == MediaType.ARTIST: + await self.provider.gql_client.add_artist_to_favorite(artist_id=item.item_id) + elif item.media_type == MediaType.ALBUM: + await self.provider.gql_client.add_album_to_favorite(album_id=item.item_id) + elif item.media_type == MediaType.TRACK: + await self.provider.gql_client.add_track_to_favorite(track_id=item.item_id) + elif item.media_type == MediaType.PLAYLIST: + await self.provider.gql_client.add_playlist_to_favorite(playlist_id=item.item_id) + elif item.media_type == MediaType.PODCAST: + await self.provider.gql_client.add_podcast_to_favorite(podcast_id=item.item_id) + elif item.media_type == MediaType.AUDIOBOOK: + await self.provider.gql_client.add_album_to_favorite(album_id=item.item_id) + else: + raise UnsupportedFeaturedException( + f"Unsupported media type for library_add: {item.media_type}" + ) + return True + + async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: + """Remove an item from the provider's library/favorites.""" + if media_type == MediaType.ARTIST: + await self.provider.gql_client.remove_artist_from_favorite(artist_id=prov_item_id) + elif media_type == MediaType.ALBUM: + await self.provider.gql_client.remove_album_from_favorite(album_id=prov_item_id) + elif media_type == MediaType.TRACK: + await self.provider.gql_client.remove_track_from_favorite(track_id=prov_item_id) + elif media_type == MediaType.PLAYLIST: + await self.provider.gql_client.remove_playlist_from_favorite(playlist_id=prov_item_id) + elif media_type == MediaType.PODCAST: + await self.provider.gql_client.remove_podcast_from_favorite(podcast_id=prov_item_id) + elif media_type == MediaType.AUDIOBOOK: + await self.provider.gql_client.remove_album_from_favorite(album_id=prov_item_id) + else: + raise UnsupportedFeaturedException( + f"Unsupported media type for library_remove: {media_type}" + ) + return True + + # -- Playlist CRUD -- + + async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: + """Add track(s) to playlist.""" + await self.provider.gql_client.add_tracks_to_playlist( + playlist_id=prov_playlist_id, track_ids=prov_track_ids + ) + await self.provider.browse_manager.invalidate_playlist_cache(prov_playlist_id) + + async def remove_playlist_tracks( + self, prov_playlist_id: str, positions_to_remove: tuple[int, ...] + ) -> None: + """Remove track(s) from playlist.""" + playlist_tracks = await self.provider.browse_manager.get_playlist_tracks( + prov_playlist_id, 0 + ) + track_ids = [ + track.item_id for track in playlist_tracks if track.position in positions_to_remove + ] + if track_ids: + await self.provider.gql_client.remove_tracks_from_playlist( + playlist_id=prov_playlist_id, track_ids=track_ids + ) + await self.provider.browse_manager.invalidate_playlist_cache(prov_playlist_id) + + async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist: + """Create a new playlist on provider with given name.""" + result = await self.provider.gql_client.create_playlist( + title=name, is_private=False, is_collaborative=False + ) + if result.playlist is None: + msg = f"Failed to create playlist '{name}' on Deezer" + raise MediaNotFoundError(msg) + playlist = await self.provider.gql_client.get_playlist(playlist_id=result.playlist.id) + if playlist is None: + msg = f"Created playlist {result.playlist.id} not found on Deezer" + raise MediaNotFoundError(msg) + return parse_playlist(self.provider, playlist, is_editable=True) diff --git a/music_assistant/providers/deezer/parsers.py b/music_assistant/providers/deezer/parsers.py new file mode 100644 index 0000000000..392792b708 --- /dev/null +++ b/music_assistant/providers/deezer/parsers.py @@ -0,0 +1,920 @@ +""" +Parsers for Deezer API response objects (GQL + GW). + +Standalone functions that convert Deezer GQL Pydantic models and GW API +dicts into Music Assistant media item models. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol + +from deezer_python_gql.generated.enums import AlbumType as DeezerAlbumType +from deezer_python_gql.generated.enums import AudiobookContributorRoles +from deezer_python_gql.generated.get_recently_played import ( + GetRecentlyPlayedMeRecentlyPlayedEdges, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodeAlbum, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodeArtist, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodeFlow, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodeFlowConfig, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodePlaylist, + GetRecentlyPlayedMeRecentlyPlayedEdgesNodeSmartTracklist, +) +from deezer_python_gql.generated.get_track import GetTrackTrack +from music_assistant_models.enums import ( + AlbumType, + ExternalID, + ImageType, + MediaType, +) +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + ItemMapping, + MediaItemChapter, + MediaItemImage, + MediaItemMetadata, + MediaItemType, + Playlist, + Podcast, + PodcastEpisode, + ProviderMapping, + Radio, + Track, + UniqueList, +) + +from music_assistant.helpers.util import infer_album_type, parse_title_and_version + +from .constants import ( + FLOW_CONFIG_PREFIX, + FLOW_PLAYLIST_ID, + PERSONAL_ALBUM_PREFIX, + PERSONAL_ARTIST_PREFIX, + SMART_TRACKLIST_PREFIX, +) +from .helpers import ( + create_virtual_playlist, +) + +if TYPE_CHECKING: + from deezer_python_gql.generated.fragments import ( + AlbumFields, + ArtistFields, + AudiobookFields, + LivestreamFields, + PlaylistFields, + PodcastEpisodeFields, + PodcastFields, + TrackFields, + ) + from deezer_python_gql.generated.get_audiobook import ( + GetAudiobookAudiobookChaptersEdges, + ) + from deezer_python_gql.generated.get_flow_config_tracks import ( + GetFlowConfigTracksFlowConfig, + ) + from deezer_python_gql.generated.get_flow_configs import ( + GetFlowConfigsMeFlowConfigsGenresEdgesNode, + GetFlowConfigsMeFlowConfigsMoodsEdgesNode, + ) + from deezer_python_gql.generated.search import ( + SearchSearchResultsAlbumsEdgesNode, + SearchSearchResultsArtistsEdgesNode, + SearchSearchResultsLivestreamsEdgesNode, + SearchSearchResultsPlaylistsEdgesNode, + SearchSearchResultsPodcastsEdgesNode, + SearchSearchResultsTracksEdgesNode, + ) + from deezer_python_gql.generated.search_flows import ( + SearchFlowsSearchResultsFlowConfigsEdgesNode, + ) + + from .provider import DeezerProvider + +# Deezer CDN image URL pattern +DEEZER_CDN_IMAGE = "https://e-cdns-images.dzcdn.net/images" + + +# -- Protocols for typed GQL attributes -- + + +class _CoverLike(Protocol): + """Protocol for GQL cover/picture objects with a `.urls` list.""" + + @property + def urls(self) -> list[str]: ... + + +class _HasUrl(Protocol): + """Protocol for GQL result objects with a `.url` field.""" + + @property + def url(self) -> object: ... + + +def _cover_image(provider: DeezerProvider, cover: _CoverLike | None) -> MediaItemImage | None: + """ + Create a THUMB image from a GQL cover/picture object with a `.urls` list. + + Returns None when the cover is absent or has no URLs. + """ + if cover and cover.urls: + return MediaItemImage( + type=ImageType.THUMB, + path=cover.urls[0], + provider=provider.instance_id, + remotely_accessible=True, + ) + return None + + +def _provider_mapping( + provider: DeezerProvider, + item_id: str, + *, + available: bool = True, + is_unique: bool | None = None, +) -> ProviderMapping: + """Create a ProviderMapping for the given item.""" + return ProviderMapping( + item_id=item_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + available=available, + is_unique=is_unique, + ) + + +# -- GQL model parsers -- + + +def _track_available( + track: TrackFields | SearchSearchResultsTracksEdgesNode | GetTrackTrack, +) -> bool: + """Determine if a track is available for streaming.""" + media = track.media + if media is None: + return False + if media.rights.sub is None: + return False + return media.rights.sub.available + + +def parse_track( + provider: DeezerProvider, + track: TrackFields | SearchSearchResultsTracksEdgesNode | GetTrackTrack, + position: int = 0, +) -> Track: + """ + Parse a GQL track model to Music Assistant Track. + + :param provider: The Deezer provider instance. + :param track: A GQL track model (fragment-based or slim search result). + :param position: Position in a track list (for playlist ordering). + """ + artists: UniqueList[Artist | ItemMapping] = UniqueList() + for edge in track.contributors.edges: + if edge.node is not None: + artists.append( + ItemMapping( + media_type=MediaType.ARTIST, + item_id=edge.node.id, + provider=provider.instance_id, + name=edge.node.name, + ) + ) + + album: Album | None = None + if track.album is not None: + album_images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, track.album.cover): + album_images.append(img) + # Use track contributors as album artists since the track-level album + # sub-query doesn't include its own contributors. This ensures the + # album gets stored with artist references when added to the library. + album_artists: UniqueList[Artist | ItemMapping] = UniqueList() + for edge in track.contributors.edges: + if edge.node is not None and edge.roles and "MAIN" in edge.roles: + album_artists.append( + ItemMapping( + media_type=MediaType.ARTIST, + item_id=edge.node.id, + provider=provider.instance_id, + name=edge.node.name, + ) + ) + year: int | None = None + if release_date := getattr(track, "release_date", None): + if parsed := parse_date(str(release_date)): + year = parsed.year + album = Album( + item_id=track.album.id, + provider=provider.instance_id, + name=track.album.display_title, + year=year, + artists=album_artists, + provider_mappings={_provider_mapping(provider, track.album.id)}, + metadata=MediaItemMetadata(images=album_images) + if album_images + else MediaItemMetadata(), + ) + + name, version = parse_title_and_version(track.title) + disc_number = 0 + track_number = position + if (disk_info := getattr(track, "disk_info", None)) is not None: + disc_number = disk_info.disk_number or 0 + track_number = disk_info.track_number or position + + item = Track( + item_id=track.id, + provider=provider.instance_id, + name=name, + version=version, + duration=track.duration, + artists=artists, + album=album, + provider_mappings={ + _provider_mapping(provider, track.id, available=_track_available(track)) + }, + metadata=_parse_track_metadata(provider, track), + track_number=track_number, + position=position, + disc_number=disc_number, + ) + if isrc := getattr(track, "isrc", None): + item.external_ids.add((ExternalID.ISRC, isrc)) + if getattr(track, "is_favorite", False): + item.favorite = True + if (popularity := getattr(track, "popularity", None)) is not None: + item.metadata.popularity = int(popularity) + return item + + +def _parse_track_metadata( + provider: DeezerProvider, + track: TrackFields | SearchSearchResultsTracksEdgesNode | GetTrackTrack, +) -> MediaItemMetadata: + """Parse track metadata (images, explicit flag, lyrics) from a GQL track model.""" + metadata = MediaItemMetadata(explicit=track.is_explicit) + if track.album is not None: + if img := _cover_image(provider, track.album.cover): + metadata.add_image(img) + # Lyrics (only present on GetTrackTrack, not on fragment-based models) + if isinstance(track, GetTrackTrack) and track.lyrics is not None: + if track.lyrics.text: + metadata.lyrics = track.lyrics.text + if track.lyrics.synchronized_lines: + lrc_lines = [ + f"{line.lrc_timestamp} {line.line}" + for line in track.lyrics.synchronized_lines + if line.lrc_timestamp + ] + if lrc_lines: + metadata.lrc_lyrics = "\n".join(lrc_lines) + return metadata + + +def parse_artist( + provider: DeezerProvider, artist: ArtistFields | SearchSearchResultsArtistsEdgesNode +) -> Artist: + """Parse a GQL artist model to Music Assistant Artist.""" + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, artist.picture): + images.append(img) + metadata = MediaItemMetadata(images=images) if images else MediaItemMetadata() + if bio := getattr(artist, "bio", None): + metadata.description = re.sub(r"<[^>]+>", "", bio.full).strip() + if (fans_count := getattr(artist, "fans_count", None)) is not None: + metadata.popularity = int(fans_count) + item = Artist( + item_id=artist.id, + provider=provider.instance_id, + name=artist.name, + media_type=MediaType.ARTIST, + provider_mappings={_provider_mapping(provider, artist.id)}, + metadata=metadata, + ) + if getattr(artist, "is_favorite", False): + item.favorite = True + return item + + +def parse_album( + provider: DeezerProvider, album: AlbumFields | SearchSearchResultsAlbumsEdgesNode +) -> Album: + """Parse a GQL album model to Music Assistant Album.""" + name, version = parse_title_and_version(album.display_title) + artists: UniqueList[Artist | ItemMapping] = UniqueList() + for edge in album.contributors.edges: + if edge.node is not None: + artists.append( + ItemMapping( + media_type=MediaType.ARTIST, + item_id=edge.node.id, + provider=provider.instance_id, + name=edge.node.name, + ) + ) + + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, album.cover): + images.append(img) + + deezer_type = album.type_ + album_type = map_album_type(deezer_type, name) + + year: int | None = None + if album.release_date: + if parsed := parse_date(str(album.release_date)): + year = parsed.year + + item = Album( + album_type=album_type, + item_id=album.id, + provider=provider.instance_id, + name=name, + version=version, + year=year, + artists=artists, + media_type=MediaType.ALBUM, + provider_mappings={_provider_mapping(provider, album.id)}, + metadata=MediaItemMetadata( + explicit=getattr(album, "is_explicit", None), + images=images, + label=getattr(album, "label", None), + copyright=getattr(album, "copyright", None), + ), + ) + if (fans_count := getattr(album, "fans_count", None)) is not None: + item.metadata.popularity = int(fans_count) + if getattr(album, "is_favorite", False): + item.favorite = True + return item + + +def parse_playlist( + provider: DeezerProvider, + playlist: PlaylistFields | SearchSearchResultsPlaylistsEdgesNode, + is_editable: bool = False, +) -> Playlist: + """ + Parse a GQL playlist model to Music Assistant Playlist. + + :param provider: The Deezer provider instance. + :param playlist: A GQL playlist model (fragment-based or slim search result). + :param is_editable: Whether the current user owns this playlist. + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, playlist.picture): + images.append(img) + owner_name = "Unknown" + if playlist.owner: + owner_name = playlist.owner.name + if not is_editable and playlist.owner.id == provider.user_id: + is_editable = True + + metadata = MediaItemMetadata(images=images) if images else MediaItemMetadata() + if (fans_count := getattr(playlist, "fans_count", None)) is not None: + metadata.popularity = int(fans_count) + if description := getattr(playlist, "description", None): + metadata.description = description + item = Playlist( + item_id=playlist.id, + provider=provider.instance_id, + name=playlist.title, + media_type=MediaType.PLAYLIST, + provider_mappings={_provider_mapping(provider, playlist.id, is_unique=is_editable)}, + metadata=metadata, + is_editable=is_editable, + owner=owner_name, + ) + if getattr(playlist, "is_favorite", False) or is_editable: + item.favorite = True + return item + + +def parse_radio( + provider: DeezerProvider, livestream: LivestreamFields | SearchSearchResultsLivestreamsEdgesNode +) -> Radio: + """ + Parse a GQL Livestream model to Music Assistant Radio. + + :param provider: The Deezer provider instance. + :param livestream: A GQL livestream model (fragment-based or slim search result). + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, livestream.cover): + images.append(img) + metadata = MediaItemMetadata(images=images) if images else MediaItemMetadata() + if description := getattr(livestream, "description", None): + metadata.description = description + return Radio( + item_id=livestream.id, + provider=provider.instance_id, + name=livestream.name, + media_type=MediaType.RADIO, + provider_mappings={_provider_mapping(provider, livestream.id)}, + metadata=metadata, + ) + + +def parse_podcast( + provider: DeezerProvider, podcast: PodcastFields | SearchSearchResultsPodcastsEdgesNode +) -> Podcast: + """ + Parse a GQL podcast model to Music Assistant Podcast. + + :param provider: The Deezer provider instance. + :param podcast: A GQL podcast model (fragment-based or slim search result). + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, podcast.cover): + images.append(img) + metadata = MediaItemMetadata( + images=images, + explicit=getattr(podcast, "is_explicit", None), + ) + if description := getattr(podcast, "description", None): + metadata.description = description + item = Podcast( + item_id=podcast.id, + provider=provider.instance_id, + name=podcast.display_title, + media_type=MediaType.PODCAST, + provider_mappings={_provider_mapping(provider, podcast.id)}, + metadata=metadata, + ) + if getattr(podcast, "is_favorite", False): + item.favorite = True + return item + + +def parse_podcast_episode( + provider: DeezerProvider, + episode: PodcastEpisodeFields, + podcast: Podcast | ItemMapping, + position: int = 0, + podcast_image_url: str | None = None, +) -> PodcastEpisode: + """ + Parse a GQL podcast episode model to Music Assistant PodcastEpisode. + + :param provider: The Deezer provider instance. + :param episode: A GQL podcast episode model inheriting from PodcastEpisodeFields. + :param podcast: Parent podcast reference. + :param position: Sort position / episode number. + :param podcast_image_url: Fallback image from parent podcast if episode has none. + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, episode.cover): + images.append(img) + elif podcast_image_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=podcast_image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + metadata = MediaItemMetadata(images=images) + if episode.description: + metadata.description = episode.description + episode_name = episode.title + if episode.publication_date: + if pub_date := parse_date(str(episode.publication_date)): + metadata.release_date = pub_date + episode_name = f"{pub_date.strftime('%Y-%m-%d')} - {episode.title}" + return PodcastEpisode( + item_id=episode.id, + provider=provider.instance_id, + name=episode_name, + duration=episode.duration, + podcast=podcast, + position=position, + media_type=MediaType.PODCAST_EPISODE, + provider_mappings={_provider_mapping(provider, episode.id)}, + metadata=metadata, + ) + + +def parse_audiobook(provider: DeezerProvider, audiobook: AudiobookFields) -> Audiobook: + """ + Parse a GQL audiobook model to Music Assistant Audiobook. + + :param provider: The Deezer provider instance. + :param audiobook: A GQL audiobook model inheriting from AudiobookFields. + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, audiobook.cover): + images.append(img) + metadata = MediaItemMetadata( + images=images, + explicit=audiobook.is_explicit, + ) + if audiobook.description: + metadata.description = audiobook.description + if audiobook.fans_count: + metadata.popularity = int(audiobook.fans_count) + + authors: UniqueList[str | Artist] = UniqueList() + narrators: UniqueList[str | Artist] = UniqueList() + for edge in audiobook.contributors.edges: + if AudiobookContributorRoles.NARRATOR in edge.roles: + narrators.append(edge.node.name) + else: + authors.append(edge.node.name) + + item = Audiobook( + item_id=audiobook.id, + provider=provider.instance_id, + name=audiobook.display_title or audiobook.id, + duration=audiobook.duration, + publisher=audiobook.publisher, + authors=authors, + narrators=narrators, + media_type=MediaType.AUDIOBOOK, + provider_mappings={_provider_mapping(provider, audiobook.id)}, + metadata=metadata, + ) + if audiobook.is_favorite: + item.favorite = True + return item + + +def parse_audiobook_from_album( + provider: DeezerProvider, album: AlbumFields | SearchSearchResultsAlbumsEdgesNode +) -> Audiobook: + """ + Create an Audiobook from an AlbumFields result (search context). + + :param provider: The Deezer provider instance. + :param album: A GQL album model (fragment-based or slim search result). + """ + images: UniqueList[MediaItemImage] = UniqueList() + if img := _cover_image(provider, album.cover): + images.append(img) + + authors: UniqueList[str | Artist] = UniqueList() + for edge in album.contributors.edges: + if edge.node is not None: + authors.append(edge.node.name) + + return Audiobook( + item_id=album.id, + provider=provider.instance_id, + name=album.display_title, + authors=authors, + media_type=MediaType.AUDIOBOOK, + provider_mappings={_provider_mapping(provider, album.id)}, + metadata=MediaItemMetadata( + images=images, + explicit=getattr(album, "is_explicit", None), + ), + ) + + +def parse_audiobook_chapters( + chapter_edges: Sequence[GetAudiobookAudiobookChaptersEdges], +) -> list[MediaItemChapter]: + """ + Build MediaItemChapter list from audiobook chapter edges. + + :param chapter_edges: List of chapter edge objects with `.node` attribute. + """ + chapters: list[MediaItemChapter] = [] + cumulative_seconds = 0.0 + for idx, edge in enumerate(chapter_edges): + if edge.node is None: + continue + duration = float(edge.node.duration) + chapters.append( + MediaItemChapter( + position=idx + 1, + name=edge.node.display_title, + start=cumulative_seconds, + end=cumulative_seconds + duration, + ) + ) + cumulative_seconds += duration + return chapters + + +def parse_recently_played_edges( + provider: DeezerProvider, + edges: list[GetRecentlyPlayedMeRecentlyPlayedEdges], +) -> list[MediaItemType]: + """Parse recently played edges into MediaItemType list.""" + items: list[MediaItemType] = [] + for edge in edges: + node = edge.node + if node is None: + continue + if isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodeAlbum): + items.append(parse_album(provider, node)) + elif isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodePlaylist): + items.append(parse_playlist(provider, node)) + elif isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodeArtist): + items.append(parse_artist(provider, node)) + elif isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodeFlow): + cover = node.cover.urls[0] if node.cover and node.cover.urls else None + items.append( + create_virtual_playlist(provider, FLOW_PLAYLIST_ID, node.title, image_url=cover) + ) + elif isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodeFlowConfig): + cover = get_flow_config_image(node) + playlist_id = f"{FLOW_CONFIG_PREFIX}{node.id}" + items.append( + create_virtual_playlist( + provider, playlist_id, f"Flow: {node.title}", image_url=cover + ) + ) + elif isinstance(node, GetRecentlyPlayedMeRecentlyPlayedEdgesNodeSmartTracklist): + cover = node.cover.urls[0] if node.cover and node.cover.urls else None + items.append( + create_virtual_playlist( + provider, + f"{SMART_TRACKLIST_PREFIX}{node.id}", + node.title, + image_url=cover, + ) + ) + return items + + +# -- GW API parsers -- + + +def parse_gw_item(provider: DeezerProvider, item: dict[str, Any]) -> MediaItemType | None: + """Parse a GW page item to a Music Assistant media item.""" + item_type = item.get("type") + data = item.get("data", {}) + try: + if item_type == "album" and data.get("ALB_ID"): + return parse_gw_audiobook(provider, data) + if item_type == "playlist" and data.get("PLAYLIST_ID"): + return parse_gw_playlist(provider, data) + if item_type == "artist" and data.get("ART_ID"): + return parse_gw_artist(provider, data) + except KeyError: + provider.logger.debug("Incomplete GW item data for type=%s, skipping", item_type) + return None + + +def parse_gw_audiobook(provider: DeezerProvider, data: dict[str, Any]) -> Audiobook: + """Parse a GW page album item to Music Assistant Audiobook.""" + album_id = str(data["ALB_ID"]) + title = data.get("ALB_TITLE", "") + + images: UniqueList[MediaItemImage] = UniqueList() + if md5 := data.get("ALB_PICTURE"): + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=deezer_cover_url(md5, "cover"), + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + + authors: UniqueList[str | Artist] = UniqueList() + if art_name := data.get("ART_NAME"): + authors.append(art_name) + + return Audiobook( + item_id=album_id, + provider=provider.instance_id, + name=title, + authors=authors, + media_type=MediaType.AUDIOBOOK, + provider_mappings={_provider_mapping(provider, album_id)}, + metadata=MediaItemMetadata(images=images), + ) + + +def parse_gw_playlist(provider: DeezerProvider, data: dict[str, Any]) -> Playlist: + """Parse a GW page playlist item to Music Assistant Playlist.""" + playlist_id = str(data["PLAYLIST_ID"]) + + images: UniqueList[MediaItemImage] = UniqueList() + if md5 := data.get("PLAYLIST_PICTURE"): + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=deezer_cover_url(md5, "playlist"), + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + + metadata = MediaItemMetadata(images=images) if images else MediaItemMetadata() + if desc := data.get("DESCRIPTION"): + metadata.description = desc + + return Playlist( + item_id=playlist_id, + provider=provider.instance_id, + name=data.get("TITLE", ""), + media_type=MediaType.PLAYLIST, + provider_mappings={_provider_mapping(provider, playlist_id)}, + metadata=metadata, + owner=data.get("PARENT_USERNAME", "Deezer"), + ) + + +def parse_gw_artist(provider: DeezerProvider, data: dict[str, Any]) -> Artist: + """Parse a GW page artist item to Music Assistant Artist.""" + artist_id = str(data["ART_ID"]) + + images: UniqueList[MediaItemImage] = UniqueList() + if md5 := data.get("ART_PICTURE"): + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=deezer_cover_url(md5, "artist"), + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + + return Artist( + item_id=artist_id, + provider=provider.instance_id, + name=data.get("ART_NAME", ""), + media_type=MediaType.ARTIST, + provider_mappings={_provider_mapping(provider, artist_id)}, + metadata=MediaItemMetadata(images=images), + ) + + +def parse_gw_track(provider: DeezerProvider, song: dict[str, Any], position: int = 0) -> Track: + """ + Parse a GW API song dict into a Music Assistant Track. + + :param provider: The Deezer provider instance. + :param song: Raw song dict from the GW API (personal_song.getList, etc.). + :param position: Position in a track list. + """ + song_id = str(song["SNG_ID"]) + is_personal = int(song_id) < 0 + artists: UniqueList[Artist | ItemMapping] = UniqueList() + art_name = song.get("ART_NAME", "") + art_id = str(song.get("ART_ID", "0")) + if art_name: + if is_personal or art_id == "0": + # Personal tracks have ART_ID=0 which doesn't exist on Deezer. + # Create a full Artist object so MA doesn't try to resolve it. + # Use a prefixed ID to avoid collisions with the track's provider mapping. + personal_art_id = f"{PERSONAL_ARTIST_PREFIX}{song_id}" + artists.append( + Artist( + item_id=personal_art_id, + provider=provider.instance_id, + name=art_name, + favorite=True, + provider_mappings={_provider_mapping(provider, personal_art_id)}, + ) + ) + else: + artists.append( + ItemMapping( + media_type=MediaType.ARTIST, + item_id=art_id, + provider=provider.instance_id, + name=art_name, + ) + ) + + album: Album | ItemMapping | None = None + alb_title = song.get("ALB_TITLE", "") + alb_id = str(song.get("ALB_ID", 0)) + if alb_title: + if is_personal or alb_id == "0": + # Personal tracks have ALB_ID=0 which doesn't exist on Deezer. + # Create a full Album object so MA doesn't try to resolve it. + # Use a prefixed ID to avoid collisions with the track's provider mapping. + personal_alb_id = f"{PERSONAL_ALBUM_PREFIX}{song_id}" + album = Album( + item_id=personal_alb_id, + provider=provider.instance_id, + name=alb_title, + favorite=True, + artists=artists, + provider_mappings={_provider_mapping(provider, personal_alb_id)}, + ) + else: + album = ItemMapping( + media_type=MediaType.ALBUM, + item_id=alb_id, + provider=provider.instance_id, + name=alb_title, + ) + + name, version = parse_title_and_version(song.get("SNG_TITLE", "")) + return Track( + item_id=song_id, + provider=provider.instance_id, + name=name, + version=version, + duration=int(song.get("DURATION", 0)), + favorite=is_personal, + artists=artists, + album=album, + provider_mappings={_provider_mapping(provider, song_id, available=True)}, + position=position, + ) + + +# -- Helper functions -- + + +def deezer_cover_url(md5: str, image_type: str = "cover", size: int = 500) -> str: + """Construct a Deezer CDN image URL from an MD5 hash.""" + return f"{DEEZER_CDN_IMAGE}/{image_type}/{md5}/{size}x{size}-000000-80-0-0.jpg" + + +def get_flow_config_image( + node: GetFlowConfigTracksFlowConfig + | GetFlowConfigsMeFlowConfigsMoodsEdgesNode + | GetFlowConfigsMeFlowConfigsGenresEdgesNode + | SearchFlowsSearchResultsFlowConfigsEdgesNode + | GetRecentlyPlayedMeRecentlyPlayedEdgesNodeFlowConfig, +) -> str | None: + """Extract the square icon URL from a FlowConfig node's visuals.""" + icon = node.visuals.hardware_square_icon + if icon and icon.urls: + url: str = icon.urls[0] + return url + return None + + +def get_gw_item_image(provider: DeezerProvider, item: dict[str, Any]) -> MediaItemImage | None: + """Extract a cover image from a GW page item.""" + data = item.get("data", {}) + item_type = item.get("type") + md5: str | None = None + img_type = "cover" + + if item_type == "album": + md5 = data.get("ALB_PICTURE") + elif item_type == "playlist": + md5 = data.get("PLAYLIST_PICTURE") + img_type = "playlist" + elif item_type == "artist": + md5 = data.get("ART_PICTURE") + img_type = "artist" + elif item_type == "channel": + pictures = item.get("pictures", []) + if pictures: + md5 = pictures[0].get("md5") + img_type = pictures[0].get("type", "misc") + + if md5: + return MediaItemImage( + type=ImageType.THUMB, + path=deezer_cover_url(md5, img_type), + provider=provider.instance_id, + remotely_accessible=True, + ) + return None + + +def apply_web_url(item: Artist | Album, gql_result: _HasUrl) -> None: + """Set web URL on provider mappings if available in the GQL result.""" + if web_url := getattr(gql_result.url, "web_url", None): + for pm in item.provider_mappings: + pm.url = web_url + + +def map_album_type(deezer_type: DeezerAlbumType | None, title: str) -> AlbumType: + """Map Deezer album type to Music Assistant AlbumType.""" + inferred = infer_album_type(title, "") + if inferred in (AlbumType.SOUNDTRACK, AlbumType.LIVE): + return inferred + if deezer_type is None: + return AlbumType.UNKNOWN + match deezer_type: + case DeezerAlbumType.ALBUM: + return AlbumType.ALBUM + case DeezerAlbumType.SINGLES: + return AlbumType.SINGLE + case DeezerAlbumType.EP: + return AlbumType.EP + case DeezerAlbumType.COMPILATIONS: + return AlbumType.COMPILATION + case _: + return AlbumType.UNKNOWN + + +def parse_date(date_value: str | None) -> datetime | None: + """Parse a date value from the GQL API to a timezone-aware datetime.""" + try: + return datetime.fromisoformat(str(date_value)) + except (ValueError, TypeError): + return None diff --git a/music_assistant/providers/deezer/provider.py b/music_assistant/providers/deezer/provider.py new file mode 100644 index 0000000000..6f6b4e6f70 --- /dev/null +++ b/music_assistant/providers/deezer/provider.py @@ -0,0 +1,292 @@ +""" +Deezer provider - provider facade. + +Thin facade that delegates to specialized manager classes: +- DeezerMediaManager: library, search, item getters, mutations +- DeezerBrowseManager: browse tree, recommendations, virtual playlists +- DeezerStreamingManager: streaming, decryption, playback callbacks +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Sequence +from datetime import datetime +from typing import TYPE_CHECKING + +from deezer_python_gql import DeezerGQLClient, GraphQLClientError +from music_assistant_models.enums import MediaType, ProviderFeature +from music_assistant_models.errors import LoginFailed + +from music_assistant.models.music_provider import MusicProvider + +from .browse import DeezerBrowseManager +from .gw_client import DeezerGWError, GWClient +from .media import DeezerMediaManager +from .streaming import DeezerStreamingManager + +if TYPE_CHECKING: + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + BrowseFolder, + ItemMapping, + MediaItemType, + Playlist, + Podcast, + PodcastEpisode, + Radio, + RecommendationFolder, + SearchResults, + Track, + ) + from music_assistant_models.streamdetails import StreamDetails + +SUPPORTED_FEATURES = { + ProviderFeature.LIBRARY_ARTISTS, + ProviderFeature.LIBRARY_ALBUMS, + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.LIBRARY_ALBUMS_EDIT, + ProviderFeature.LIBRARY_TRACKS_EDIT, + ProviderFeature.LIBRARY_ARTISTS_EDIT, + ProviderFeature.LIBRARY_PLAYLISTS_EDIT, + ProviderFeature.ALBUM_METADATA, + ProviderFeature.TRACK_METADATA, + ProviderFeature.ARTIST_METADATA, + ProviderFeature.ARTIST_ALBUMS, + ProviderFeature.ARTIST_TOPTRACKS, + ProviderFeature.BROWSE, + ProviderFeature.SEARCH, + ProviderFeature.PLAYLIST_TRACKS_EDIT, + ProviderFeature.PLAYLIST_CREATE, + ProviderFeature.RECOMMENDATIONS, + ProviderFeature.SIMILAR_TRACKS, + ProviderFeature.SIMILAR_ARTISTS, + ProviderFeature.LYRICS, + ProviderFeature.LIBRARY_PODCASTS, + ProviderFeature.LIBRARY_PODCASTS_EDIT, + ProviderFeature.LIBRARY_AUDIOBOOKS, + ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT, +} + +CONF_ARL_TOKEN = "arl_token" + + +class DeezerProvider(MusicProvider): + """ + Deezer provider support. + + Delegates to specialized manager classes for clean separation of concerns. + """ + + gql_client: DeezerGQLClient + gw_client: GWClient + media_manager: DeezerMediaManager + browse_manager: DeezerBrowseManager + streaming_manager: DeezerStreamingManager + user_id: str + + async def handle_async_init(self) -> None: + """Handle async init of the Deezer provider.""" + arl_token = str(self.config.get_value(CONF_ARL_TOKEN)) + + try: + self.gql_client = DeezerGQLClient(arl=arl_token, session=self.mass.http_session) + me = await self.gql_client.get_me() + if not me: + msg = "Authentication returned no user data" + raise GraphQLClientError(msg) + self.user_id = me.id + self.gw_client = GWClient(self.mass.http_session, arl_token) + await self.gw_client.setup() + except (GraphQLClientError, DeezerGWError) as err: + raise LoginFailed("Deezer authentication failed. Please check your ARL token.") from err + + self.media_manager = DeezerMediaManager(self) + self.browse_manager = DeezerBrowseManager(self) + self.streaming_manager = DeezerStreamingManager(self) + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + await super().unload(is_removed) + + # -- Library retrieval -- + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve all library artists from Deezer.""" + async for item in self.media_manager.get_library_artists(): + yield item + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve all library albums from Deezer.""" + async for item in self.media_manager.get_library_albums(): + yield item + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve all library playlists from Deezer.""" + async for item in self.media_manager.get_library_playlists(): + yield item + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve all library tracks from Deezer.""" + async for item in self.media_manager.get_library_tracks(): + yield item + + async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: + """Retrieve library/subscribed podcasts from Deezer.""" + async for item in self.media_manager.get_library_podcasts(): + yield item + + async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: + """Retrieve library/subscribed audiobooks from Deezer.""" + async for item in self.media_manager.get_library_audiobooks(): + yield item + + # -- Search -- + + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 5 + ) -> SearchResults: + """Perform search on music provider.""" + return await self.media_manager.search(search_query, media_types, limit) + + # -- Item getters -- + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + return await self.media_manager.get_artist(prov_artist_id) + + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + return await self.media_manager.get_album(prov_album_id) + + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + return await self.media_manager.get_track(prov_track_id) + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + return await self.media_manager.get_playlist(prov_playlist_id) + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio/livestream details by id.""" + return await self.media_manager.get_radio(prov_radio_id) + + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get full podcast details by id.""" + return await self.media_manager.get_podcast(prov_podcast_id) + + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Get (full) podcast episode details by id.""" + return await self.media_manager.get_podcast_episode(prov_episode_id) + + async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: + """Get full audiobook details by id.""" + return await self.media_manager.get_audiobook(prov_audiobook_id) + + # -- Content getters -- + + async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + """Get all tracks in an album.""" + return await self.media_manager.get_album_tracks(prov_album_id) + + async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: + """Get playlist tracks.""" + return await self.browse_manager.get_playlist_tracks(prov_playlist_id, page) + + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + """Get albums by an artist.""" + return await self.media_manager.get_artist_albums(prov_artist_id) + + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Get top tracks of an artist.""" + return await self.media_manager.get_artist_toptracks(prov_artist_id) + + async def get_podcast_episodes( + self, prov_podcast_id: str + ) -> AsyncGenerator[PodcastEpisode, None]: + """Get all episodes for a given podcast.""" + async for ep in self.media_manager.get_podcast_episodes(prov_podcast_id): + yield ep + + async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: + """Retrieve a dynamic list of tracks based on the provided item.""" + return await self.media_manager.get_similar_tracks(prov_track_id, limit) + + async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]: + """Retrieve a list of artists similar to the provided artist.""" + return await self.media_manager.get_similar_artists(prov_artist_id, limit) + + # -- Library mutations -- + + async def library_add(self, item: MediaItemType) -> bool: + """Add an item to the provider's library/favorites.""" + return await self.media_manager.library_add(item) + + async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: + """Remove an item from the provider's library/favorites.""" + return await self.media_manager.library_remove(prov_item_id, media_type) + + # -- Playlist CRUD -- + + async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: + """Add track(s) to playlist.""" + await self.media_manager.add_playlist_tracks(prov_playlist_id, prov_track_ids) + + async def remove_playlist_tracks( + self, prov_playlist_id: str, positions_to_remove: tuple[int, ...] + ) -> None: + """Remove track(s) from playlist.""" + await self.media_manager.remove_playlist_tracks(prov_playlist_id, positions_to_remove) + + async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist: + """Create a new playlist on provider with given name.""" + return await self.media_manager.create_playlist(name, media_types) + + # -- Browse & Recommendations -- + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse Deezer content.""" + return await self.browse_manager.browse(path, super().browse) + + async def recommendations(self) -> list[RecommendationFolder]: + """Get Deezer's recommendations including Flow and personalized content.""" + return await self.browse_manager.recommendations() + + # -- Streaming -- + + async def get_resume_position( + self, item_id: str, media_type: MediaType + ) -> tuple[bool, int, datetime | None]: + """Get the resume position for a podcast episode.""" + return await self.streaming_manager.get_resume_position(item_id, media_type) + + async def on_played( + self, + media_type: MediaType, + prov_item_id: str, + fully_played: bool, + position: int, + media_item: MediaItemType, + is_playing: bool = False, + ) -> None: + """Handle callback when a podcast episode has been played or is playing.""" + await self.streaming_manager.on_played( + media_type, prov_item_id, fully_played, position, media_item, is_playing + ) + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + return await self.streaming_manager.get_stream_details(item_id, media_type) + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + async for chunk in self.streaming_manager.get_audio_stream(streamdetails, seek_position): + yield chunk + + async def on_streamed(self, streamdetails: StreamDetails) -> None: + """Handle callback when an item completed streaming.""" + await self.streaming_manager.on_streamed(streamdetails) diff --git a/music_assistant/providers/deezer/streaming.py b/music_assistant/providers/deezer/streaming.py new file mode 100644 index 0000000000..a411c81964 --- /dev/null +++ b/music_assistant/providers/deezer/streaming.py @@ -0,0 +1,375 @@ +""" +Streaming, decryption, and playback callbacks for the Deezer provider. + +Handles stream URL resolution, Blowfish decryption for track audio, +radio/podcast stream details, and listen logging callbacks. +""" + +from __future__ import annotations + +import hashlib +import uuid +from collections.abc import AsyncGenerator +from datetime import datetime +from math import ceil +from typing import TYPE_CHECKING + +from aiohttp import ClientTimeout +from Crypto.Cipher import Blowfish +from music_assistant_models.enums import ContentType, MediaType, StreamType +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import AudioFormat, MediaItemType +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.helpers.app_vars import app_var # type: ignore[attr-defined] +from music_assistant.helpers.datetime import utc_timestamp + +from .gw_client import DeezerGWError +from .helpers import fetch_all_audiobook_chapter_edges, fetch_all_bookmarks + +if TYPE_CHECKING: + from .provider import DeezerProvider + + +class DeezerStreamingManager: + """Handles streaming, decryption, and playback lifecycle for Deezer.""" + + def __init__(self, provider: DeezerProvider) -> None: + """Initialize streaming manager.""" + self.provider = provider + self.mass = provider.mass + self.instance_id = provider.instance_id + self.domain = provider.domain + self.logger = provider.logger + + # -- Resume position -- + + async def get_resume_position( + self, item_id: str, media_type: MediaType + ) -> tuple[bool, int, datetime | None]: + """ + Get the resume position for a podcast episode. + + :param item_id: The provider-specific episode ID. + :param media_type: The media type (only PODCAST_EPISODE is supported). + :returns: Tuple of (fully_played, resume_position_ms, timestamp). + """ + if media_type != MediaType.PODCAST_EPISODE: + return (False, 0, None) + bookmarks = await fetch_all_bookmarks(self.provider.gql_client) + if item_id in bookmarks: + is_played, position_ms = bookmarks[item_id] + return (is_played, position_ms, None) + return (False, 0, None) + + # -- Playback callbacks -- + + async def on_played( + self, + media_type: MediaType, + prov_item_id: str, + fully_played: bool, + position: int, + media_item: MediaItemType, + is_playing: bool = False, + ) -> None: + """ + Handle callback when a podcast episode has been played or is playing. + + Syncs playback progress back to Deezer's bookmark/play-state system. + Only handles podcast episodes — Deezer's Pipe API has no track listen logging. + """ + if media_type != MediaType.PODCAST_EPISODE: + return + if fully_played: + await self.provider.gql_client.mark_as_played_podcast_episode(episode_id=prov_item_id) + elif position == 0 and not is_playing: + await self.provider.gql_client.mark_as_not_played_podcast_episode( + episode_id=prov_item_id + ) + elif is_playing or position > 0: + await self.provider.gql_client.bookmark_podcast_episode( + episode_id=prov_item_id, offset=position + ) + + # -- Stream details -- + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + if media_type == MediaType.RADIO: + return await self._get_radio_stream_details(item_id) + if media_type == MediaType.PODCAST_EPISODE: + return await self._get_podcast_episode_stream_details(item_id) + if media_type == MediaType.AUDIOBOOK: + return await self._get_audiobook_stream_details(item_id) + return await self._get_track_stream_details(item_id) + + async def _get_track_stream_details(self, item_id: str) -> StreamDetails: + """Return stream details for a regular Deezer track.""" + url_details, song_data = await self.provider.gw_client.get_deezer_track_urls(item_id) + url = url_details["sources"][0]["url"] + size_key = f"FILESIZE_{url_details['format']}" + size = int(song_data.get(size_key) or song_data.get("FILESIZE_MP3_MISC") or 0) + return StreamDetails( + item_id=item_id, + provider=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(url_details["format"].split("_")[0]) + ), + stream_type=StreamType.CUSTOM, + duration=int(song_data["DURATION"]), + data={ + "url": url, + "format": url_details["format"], + "track_id": str(song_data["SNG_ID"]), + }, + size=size, + can_seek=True, + allow_seek=True, + ) + + async def _get_audiobook_stream_details(self, item_id: str) -> StreamDetails: + """ + Return stream details for a Deezer audiobook. + + Resolves all chapter IDs and durations. Each chapter is streamed + as a regular encrypted track via get_audio_stream. + """ + all_edges = await fetch_all_audiobook_chapter_edges(self.provider.gql_client, item_id) + + chapter_ids: list[str] = [] + chapter_durations_ms: list[int] = [] + for edge in all_edges: + if edge.node is None: + continue + chapter_ids.append(edge.node.id) + chapter_durations_ms.append(edge.node.duration * 1000) + + if not chapter_ids: + raise MediaNotFoundError(f"No chapters found for audiobook {item_id}") + + # Probe the first chapter to determine audio format + first_url_details, _ = await self.provider.gw_client.get_deezer_track_urls(chapter_ids[0]) + total_duration = sum(chapter_durations_ms) // 1000 + + return StreamDetails( + item_id=item_id, + provider=self.instance_id, + media_type=MediaType.AUDIOBOOK, + audio_format=AudioFormat( + content_type=ContentType.try_parse(first_url_details["format"].split("_")[0]) + ), + stream_type=StreamType.CUSTOM, + duration=total_duration, + data={ + "chapter_ids": chapter_ids, + "chapter_durations_ms": chapter_durations_ms, + }, + can_seek=True, + allow_seek=True, + ) + + async def _get_radio_stream_details(self, item_id: str) -> StreamDetails: + """Return stream details for a Deezer livestream (radio station).""" + result = await self.provider.gql_client.get_livestream(livestream_id=item_id) + if result is None or not result.media: + raise MediaNotFoundError(f"Radio {item_id} has no stream URL") + # Prefer HLS, then AAC, then MP3 + best_media = result.media[0] + for media in result.media: + if media.codec and media.codec.type_ == "hls": + best_media = media + break + content_type = ContentType.UNKNOWN + if best_media.codec: + content_type = ContentType.try_parse(best_media.codec.type_) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=best_media.codec.bitrate if best_media.codec else None, + ), + media_type=MediaType.RADIO, + stream_type=StreamType.HTTP, + path=best_media.url, + can_seek=False, + allow_seek=False, + ) + + async def _get_podcast_episode_stream_details(self, item_id: str) -> StreamDetails: + """Return stream details for a Deezer podcast episode.""" + result = await self.provider.gql_client.get_podcast_episode(podcast_episode_id=item_id) + if result is None or not result.media: + raise MediaNotFoundError(f"Podcast episode {item_id} has no stream URL") + content_type = ContentType.UNKNOWN + if result.media.codec: + content_type = ContentType.try_parse(result.media.codec.type_) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=result.media.codec.bitrate if result.media.codec else None, + ), + media_type=MediaType.PODCAST_EPISODE, + stream_type=StreamType.HTTP, + path=result.media.url, + duration=result.duration, + can_seek=True, + allow_seek=True, + ) + + # -- Audio stream (Blowfish decryption) -- + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item.""" + if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict): + async for chunk in self._stream_audiobook_chapters(streamdetails, seek_position): + yield chunk + return + async for chunk in self._stream_encrypted_track(streamdetails, seek_position): + yield chunk + + async def _stream_audiobook_chapters( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Stream audiobook by iterating through encrypted chapter tracks.""" + chapter_ids: list[str] = streamdetails.data["chapter_ids"] + chapter_durations_ms: list[int] = streamdetails.data["chapter_durations_ms"] + + # Resolve which chapter to start from based on seek_position + start_chapter = 0 + chapter_seek = 0 + if seek_position > 0: + seek_ms = seek_position * 1000 + accumulated_ms = 0 + for i, dur_ms in enumerate(chapter_durations_ms): + if accumulated_ms + dur_ms > seek_ms: + start_chapter = i + chapter_seek = (seek_ms - accumulated_ms) // 1000 + break + accumulated_ms += dur_ms + else: + start_chapter = max(len(chapter_ids) - 1, 0) + chapter_seek = 0 + + prev_chapter: StreamDetails | None = None + current_chapter: StreamDetails | None = None + try: + for i in range(start_chapter, len(chapter_ids)): + chapter_id = chapter_ids[i] + try: + url_details, song_data = await self.provider.gw_client.get_deezer_track_urls( + chapter_id + ) + except (DeezerGWError, MediaNotFoundError, KeyError): + self.logger.warning("Failed to get URL for audiobook chapter %s", chapter_id) + continue + url = url_details["sources"][0]["url"] + size_key = f"FILESIZE_{url_details['format']}" + size = int(song_data.get(size_key) or song_data.get("FILESIZE_MP3_MISC") or 0) + duration = int(song_data["DURATION"]) + chapter_details = StreamDetails( + item_id=chapter_id, + provider=self.instance_id, + audio_format=streamdetails.audio_format, + stream_type=StreamType.CUSTOM, + duration=duration, + data={ + "url": url, + "format": url_details["format"], + "track_id": str(song_data["SNG_ID"]), + }, + size=size, + ) + # Log the previous chapter as fully played before starting the next + if prev_chapter and "start_ts" in prev_chapter.data: + self.mass.create_task( + self.provider.gw_client.log_listen(last_track=prev_chapter) + ) + current_chapter = chapter_details + seek = chapter_seek if i == start_chapter else 0 + async for chunk in self._stream_encrypted_track(chapter_details, seek): + yield chunk + prev_chapter = chapter_details + current_chapter = None + finally: + # Log the last chapter that was playing (completed or cancelled) + last = current_chapter or prev_chapter + if last and "start_ts" in last.data: + self.mass.create_task(self.provider.gw_client.log_listen(last_track=last)) + + async def _stream_encrypted_track( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Stream and decrypt a single encrypted Deezer track.""" + blowfish_key = self._get_blowfish_key(streamdetails.data["track_id"]) + chunk_index = 0 + timeout = ClientTimeout(total=None, connect=30, sock_read=600) + headers: dict[str, str] = {} + + # Seek by skipping chunks (Range header causes malformed audio) + if seek_position and streamdetails.size and streamdetails.duration: + chunk_count = ceil(streamdetails.size / 2048) + skip_chunks = int(chunk_count / streamdetails.duration) * seek_position + else: + skip_chunks = 0 + + buffer = bytearray() + streamdetails.data["start_ts"] = utc_timestamp() + streamdetails.data["stream_id"] = uuid.uuid1() + self.mass.create_task(self.provider.gw_client.log_listen(next_track=streamdetails.item_id)) + async with self.mass.http_session.get( + streamdetails.data["url"], headers=headers, timeout=timeout + ) as resp: + if resp.status != 200: + raise MediaNotFoundError( + f"Failed to stream track {streamdetails.item_id}: HTTP {resp.status}" + ) + async for chunk in resp.content.iter_chunked(2048): + buffer += chunk + if len(buffer) >= 2048: + if chunk_index >= skip_chunks or chunk_index == 0: + if chunk_index % 3 > 0: + yield bytes(buffer[:2048]) + else: + yield self._decrypt_chunk(bytes(buffer[:2048]), blowfish_key) + + chunk_index += 1 + del buffer[:2048] + yield bytes(buffer) + + async def on_streamed(self, streamdetails: StreamDetails) -> None: + """Handle callback when an item completed streaming.""" + if not isinstance(streamdetails.data, dict) or "start_ts" not in streamdetails.data: + return + await self.provider.gw_client.log_listen(last_track=streamdetails) + + # -- Decryption helpers -- + + @staticmethod + def _md5(data: str, data_type: str = "ascii") -> str: + md5sum = hashlib.md5() + md5sum.update(data.encode(data_type)) + return md5sum.hexdigest() + + def _get_blowfish_key(self, track_id: str) -> str: + """Get blowfish key to decrypt a chunk of a track.""" + secret = app_var(5) + id_md5 = self._md5(track_id) + return "".join( + chr(ord(id_md5[i]) ^ ord(id_md5[i + 16]) ^ ord(secret[i])) for i in range(16) + ) + + @staticmethod + def _decrypt_chunk(chunk: bytes, blowfish_key: str) -> bytes: + """Decrypt a given chunk using the blow fish key.""" + cipher = Blowfish.new( + blowfish_key.encode("ascii"), + Blowfish.MODE_CBC, + b"\x00\x01\x02\x03\x04\x05\x06\x07", + ) + return cipher.decrypt(chunk) # type: ignore[no-any-return,unused-ignore] diff --git a/requirements_all.txt b/requirements_all.txt index 87b4508a05..f3615b4f5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -31,7 +31,7 @@ certifi==2025.11.12 chardet>=5.2.0 colorlog==6.10.1 cryptography==46.0.7 -deezer-python-async==0.3.0 +deezer-python-gql==0.17.0 defusedxml==0.7.1 deno==2.7.12 duration-parser==1.0.1