Load Discover recommendation rows on demand and improve Recently Played - #4487
marcelveldt merged 39 commits into
Conversation
Resolve controller.py conflicts: keep both the recommendations subcontroller and upstream's RecencyEngine; port the provider-recommendations timeout (upstream music-assistant#4470) into RecommendationsController._provider_recommendations and drop the now-unused import from the music controller.
|
Should we perhaps consider having all RecommendationFolder providers update their systems with this revision to offer a central configuration page for enabling/disabling Recommendation Settings (for the Discover Page)? Personally, I find it somewhat annoying to have to navigate to each provider's settings to turn it on or off. My idea is to always include a default enable/disable setting in the RecommendationFolder and then display a settings page via the UI with all providers, where everyone can make their own adjustments. |
Reconstruct controller.py against upstream (which removed get_dynamic_radio_tracks and reworked the recommendations methods): keep the recommendations subcontroller, delete the legacy recommendations()/_get_default_recommendations/_get_provider_recommendations, and port upstream's improvements into the subcontroller (required_scope=Scope.LIBRARY_READ, summary=False). Bypassed pre-commit mypy: the only failures are pre-existing errors in the untouched upstream file yandex_music/auth.py (untyped yandex-music lib + strict warn_return_any); our changes pass ruff and mypy.
Strongly agree — a single Discover settings surface beats per-provider toggles. This is exactly what @marcelveldt poked me about suggesting I didn't take this far enough: every row (library + provider) declares an enabled_by_default, the client fetches a lightweight "list of rows" and toggles them in one central page, and the choices persist server-side so they sync across clients. It also lazy-loads each row's items, so disabled rows cost nothing. |
|
Exactly 👍 |
Merging this PR will not alter performance
Comparing Footnotes
|
Temporary git dependency on the fork's model branch (adds enabled_by_default to RecommendationFolder); swapped for a released music-assistant-models version before this leaves draft.
- Recommendation rows carry an enabled_by_default hint so noisy/heavy rows ship off by default; the client hides them until opted in (visibility owned by the frontend's discover.rows preference). - music/recommendations takes an optional `wanted` list of row URIs: providers whose rows are all unwanted are never called, and only the wanted rows are returned. Providers may opt in (a `wanted` parameter, detected via inspect.signature) to build only the requested rows; musicme and opensubsonic do.
7834e14 to
23fb8b3
Compare
- Row item_ids are a LibraryRowID StrEnum shared by the rows listing and the items dispatch (review: turn these into constants). - Library item queries return slim summary items again — the summary=False overrides are gone, the list-view default applies (review: avoid fully hydrated objects per item). - library_items() returns a UniqueList directly instead of the controller re-wrapping it. - Method-order baseline regenerated (the upstream merge reset entries for the converted providers).
There was a problem hiding this comment.
Pull request overview
This pull request refactors Music Assistant’s recommendations into a two-method model (rows catalog + per-row items), introduces a dedicated recommendations subcontroller/API endpoints, and adjusts “Recently played” behavior plus related provider/tooling and tests to match the new contract.
Changes:
- Added
music/recommendations(rows, no items) andmusic/recommendations/items(per-row items) via a newmass.music.recommendationssubcontroller. - Updated provider contracts from bulk
recommendations()toget_recommendations()+get_recommendation_items(item_id)(including a new cached-payload mixin for “single payload” providers). - Updated translations and added/updated extensive provider and controller tests for laziness, caching, and regressions (including podcast play credit behavior).
Reviewed changes
Copilot reviewed 110 out of 110 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/providers/zvuk_music/test_recommendations.py | New tests for Zvuk’s two-method recommendations contract. |
| tests/providers/zvuk_music/test_provider_browse.py | Removes legacy recommendations() browse tests now replaced by the split contract. |
| tests/providers/yousee/test_recommendations.py | Adds two-method contract tests for YouSee including shared payload fetch behavior. |
| tests/providers/yousee/conftest.py | Adds fixtures for YouSee provider tests, including cache/task wiring. |
| tests/providers/yousee/init.py | Declares YouSee tests package. |
| tests/providers/tunein/test_recommendations.py | Adds two-method contract tests for TuneIn, including warm-cache behavior. |
| tests/providers/tunein/conftest.py | Adds TuneIn provider test fixture. |
| tests/providers/tunein/init.py | Declares TuneIn tests package. |
| tests/providers/tidal/test_provider.py | Removes legacy recommendations() delegation test after contract change. |
| tests/providers/tidal/test_parsers.py | Explicit UTF-8 decoding for snapshot fixtures. |
| tests/providers/soundcloud/test_recommendations.py | Adds SoundCloud two-method recommendations tests (payload rows + feed row). |
| tests/providers/soundcloud/conftest.py | Adds SoundCloud provider test fixture. |
| tests/providers/soundcloud/init.py | Declares SoundCloud tests package. |
| tests/providers/sonic_similarity/test_recommendations.py | Regression test for warm-cache hits on recommendation-items path. |
| tests/providers/sonic_similarity/conftest.py | Updates fixture comments/config for new recommendations structure. |
| tests/providers/smart_playlist/test_recommendations.py | Adds Smart Playlist provider recommendations tests for split contract. |
| tests/providers/qqmusic/test_recommendations.py | Adds QQ Music two-method recommendations tests incl. payload cache behavior. |
| tests/providers/opensubsonic/test_parsers.py | Explicit UTF-8 decoding for async fixture reads. |
| tests/providers/opensubsonic/conftest.py | Adds Open Subsonic provider test fixture. |
| tests/providers/nugs/test_recommendations.py | Adds Nugs two-method recommendations tests including warm-cache hit. |
| tests/providers/nugs/conftest.py | Adds Nugs provider test fixture. |
| tests/providers/nugs/init.py | Declares Nugs tests package. |
| tests/providers/nicovideo/test_converters.py | Makes snapshot naming platform-stable via as_posix(). |
| tests/providers/neteasecloudmusic/test_recommendations.py | Adds NetEase Cloud Music two-method recommendations tests. |
| tests/providers/neteasecloudmusic/conftest.py | Adds NetEase provider test fixture. |
| tests/providers/musicbrainz/test_recommendations.py | Updates MusicBrainz recommendation tests to validate rows-without-items + items retrieval. |
| tests/providers/lastfm_recommendations/test_recommendations.py | Adds Last.fm Recommendations two-method contract tests. |
| tests/providers/lastfm_recommendations/init.py | Declares Last.fm Recommendations tests package. |
| tests/providers/jellyfin/test_parsers.py | Explicit UTF-8 decoding for async fixture reads. |
| tests/providers/itunes_podcasts/test_recommendations.py | Adds iTunes Podcasts two-method recommendations tests. |
| tests/providers/itunes_podcasts/init.py | Declares iTunes Podcasts tests package. |
| tests/providers/fastmcp_server/test_context.py | Updates MCP tool tests for split recommendations/row-items tooling. |
| tests/providers/fastmcp_server/conftest.py | Updates MCP test mass mock to expose recommendations subcontroller methods. |
| tests/providers/demo_recommendation_override/test_demo_recommendation_override.py | Verifies demo provider stubs for new recommendations methods. |
| tests/providers/demo_recommendation_override/init.py | Declares demo override tests package. |
| tests/providers/deezer/conftest.py | Adds Deezer provider test fixture wiring browse manager state. |
| tests/providers/deezer/init.py | Declares Deezer tests package. |
| tests/providers/bbc_sounds/test_recommendations.py | Adds BBC Sounds two-method recommendations tests via payload mixin. |
| tests/providers/bbc_sounds/conftest.py | Adds BBC Sounds provider test fixture. |
| tests/providers/bbc_sounds/init.py | Declares BBC Sounds tests package. |
| tests/providers/bandcamp/test_recommendations.py | Adds Bandcamp two-method recommendations tests for feed/wishlist rows. |
| tests/providers/bandcamp/test_provider.py | Removes legacy recommendations() tests; keeps feed browse tests. |
| tests/providers/audiobookshelf/test_recommendations.py | Adds Audiobookshelf two-method recommendations tests (shelves + browse row). |
| tests/providers/audiobookshelf/conftest.py | Adds Audiobookshelf provider test fixture. |
| tests/providers/audiobookshelf/init.py | Declares Audiobookshelf tests package. |
| tests/providers/_demo_plugin_provider/test_recommendations.py | Adds demo plugin provider tests for new recommendations stubs. |
| tests/providers/_demo_plugin_provider/init.py | Declares demo plugin provider tests package. |
| tests/controllers/music/test_podcast_play_credit.py | Integration tests for podcast episode play crediting the parent show. |
| scripts/lint_baselines/private_methods_not_last.txt | Updates baseline entries affected by method reordering/refactors. |
| music_assistant/translations/en.json | Adds/updates translation keys for new/adjusted recommendation rows. |
| music_assistant/strings.json | Adds string keys for new builtin recommendation rows. |
| music_assistant/providers/zvuk_music/provider.py | Implements get_recommendations + get_recommendation_items for Zvuk. |
| music_assistant/providers/ytmusic/init.py | Migrates YouTube Music recommendations to split API + payload mixin usage. |
| music_assistant/providers/yousee/provider.py | Migrates YouSee recommendations to split API + payload mixin usage. |
| music_assistant/providers/yandex_music/strings.json | Updates mix row strings to remove placeholder formatting. |
| music_assistant/providers/yandex_music/provider.py | Refactors recommendations into fast rows + per-row items method. |
| music_assistant/providers/tunein/init.py | Refactors TuneIn recommendations into rows + cached per-row items. |
| music_assistant/providers/tidal/provider.py | Migrates Tidal recommendations to split API + payload mixin usage. |
| music_assistant/providers/soundcloud/init.py | Migrates SoundCloud recommendations to split API; isolates feed fetch + caching. |
| music_assistant/providers/sonic_similarity/provider.py | Refactors recommendations into rows + per-row items; fixes caching placement. |
| music_assistant/providers/sonic_similarity/constants.py | Updates wording to reflect new items-path tunables. |
| music_assistant/providers/smart_playlist/init.py | Refactors Smart Playlist recommendations into rows + per-row items. |
| music_assistant/providers/qqmusic/init.py | Refactors QQ Music recommendations into rows + per-row items; keeps per-row caching. |
| music_assistant/providers/opensubsonic/sonic_provider.py | Refactors Open Subsonic recommendations into rows + per-row items with folder-level caching. |
| music_assistant/providers/nugs/init.py | Refactors Nugs recommendations into rows + per-row items with row-level caching. |
| music_assistant/providers/nicovideo/provider_mixins/explorer.py | Refactors NicoVideo recommendations into fast rows + cached per-row items. |
| music_assistant/providers/musicbrainz/recommendations.py | Splits MusicBrainz recommendations into rows + items while keeping SWR cache behavior. |
| music_assistant/providers/musicbrainz/provider.py | Exposes MusicBrainz provider get_recommendations + get_recommendation_items. |
| music_assistant/providers/lastfm_recommendations/init.py | Splits Last.fm Recommendations provider into rows + items off in-memory folders. |
| music_assistant/providers/kion_music/strings.json | Updates mix row strings to remove placeholder formatting. |
| music_assistant/providers/kion_music/provider.py | Refactors recommendations into fast rows + per-row items method. |
| music_assistant/providers/itunes_podcasts/init.py | Splits iTunes Podcasts recommendations into rows + per-row items; adds stable row constant. |
| music_assistant/providers/fastmcp_server/tools/metadata.py | Updates MCP tools to return recommendation rows briefs + per-row items tool. |
| music_assistant/providers/fastmcp_server/models.py | Updates brief models (rows now carry provider/item_id; new item brief type). |
| music_assistant/providers/deezer/provider.py | Migrates Deezer provider to split recommendations and payload mixin integration. |
| music_assistant/providers/deezer/browse.py | Refactors Deezer browse manager recommendations into rows + per-row items routing. |
| music_assistant/providers/bbc_sounds/init.py | Migrates BBC Sounds recommendations to split API + payload mixin integration. |
| music_assistant/providers/bandcamp/init.py | Refactors Bandcamp recommendations into rows + per-row items. |
| music_assistant/providers/audiobookshelf/init.py | Migrates Audiobookshelf recommendations to split API + payload mixin integration. |
| music_assistant/providers/apple_music/recommendations.py | Adjusts Apple Music station resolution logic to work with payload mixin caching/refresh. |
| music_assistant/providers/apple_music/provider.py | Migrates Apple Music provider to split API + payload mixin integration. |
| music_assistant/providers/_demo_plugin_provider/init.py | Updates demo plugin provider template to new recommendations method signatures. |
| music_assistant/providers/_demo_music_provider/init.py | Updates demo music provider template to new recommendations method signatures. |
| music_assistant/models/recommendation_payload.py | Adds RecommendationPayloadMixin for persistent cached “single payload” recommendations. |
| music_assistant/models/plugin.py | Updates plugin provider base contract to split recommendations methods. |
| music_assistant/models/music_provider.py | Updates music provider base contract and browse routing for split recommendations. |
| music_assistant/models/metadata_provider.py | Updates metadata provider base contract to split recommendations methods. |
| music_assistant/controllers/music/recommendations/library.py | Introduces builtin library recommendation rows/items implementation. |
| music_assistant/controllers/music/recommendations/controller.py | Adds recommendations subcontroller and registers the two new API endpoints. |
| music_assistant/controllers/music/recommendations/init.py | Declares recommendations controller package. |
| music_assistant/controllers/music/constants.py | Replaces single provider timeout with separate rows/items timeouts. |
The items API resolves the owning provider from the row's provider field; opensubsonic and smart_playlist rows carried the domain, which resolves to the wrong instance (or nothing) when multiple instances exist. Also give the recently-added library rows valid mdi- icons (the bare music-note-plus value predates this branch).
- RecommendationPayloadMixin owns its caching explicitly: the last payload is kept in memory (no cache-db round trip on warm calls, no cold-start double fetch between the rows and items calls), every successful fetch persists under the pre-existing cache key, stale payloads are served while exactly one deduplicated background refresh runs, and all background work goes through mass.create_task with exceptions routed to the provider logger. - The payload TTL is an overridable class attribute; yousee keeps its 24h interval, soundcloud/plex/bbc_sounds their 3h. - Row copies use dataclasses.replace so no folder fields are dropped. - lastfm rebuilds its folders into a local list and swaps on success, so rows keep serving during (or after a failed) rate-limited rebuild. - Podcast episode plays credit the parent's library item when it exists, falling back to provider ids — no more duplicate shows in Recently Played. - Recently Played includes GENRE again (lost in the container-rollup rework). - API_SCHEMA_VERSION bumped to 39 for the new items command and the changed music/recommendations response.
…er' into feat/recommendations-v2
marcelveldt
left a comment
There was a problem hiding this comment.
Amazing work, @chrisuthe !
|
@dmoo500 - I believe this was blocking some of you work, should be good to go now! |
The Discover recommendations experience changed in music-assistant/server#4487 and music-assistant/frontend#2141, while the user interface guide still described the previous behavior. - Explain per-row loading, provider-supplied rows, and edit-mode controls - Document default-hidden rows and the updated listening-history rows
Review feedback: instead of inferring the response shape from the rows themselves (all-empty heuristic) and probing the first row before fanning out, gate on server_info.schema_version. The rows/items split landed in schema 39 (music-assistant/server#4487), so the capability is known before any request is made. This removes the heuristic, the probe, and the first()/drop(1) positional logic: on schema 39+ every row's items are fetched uniformly; below it the embedded items are used and the per-row command is never called — including for legitimately empty rows, which previously cost one probing request. The recommendation fetches also move onto Dispatchers.IO at the repository seam, so response deserialization for a page worth of rows happens off the main thread for both the home screen and CarPlay. LOCAL_SCHEMA_VERSION moves up to 39 to match what the client now implements and tests against.
The rule's own citations (#4487, frontend#1911) are command-only, and maintainers bump for API commands and shared models — not raw HTTP routes or opt-in PROVIDER_EVENT subscriptions. Tighten the mined rule to match (dropping the broad "changing client-visible behavior" clause) in both derived files, and drop the now-redundant guard. Fixes the internal contradiction the Copilot review flagged.
What does this implement/fix?
This improves the recommendations system and makes the Discover page load only the rows it actually shows, via a two-method model (as discussed in review):
music/recommendationsreturns every available row (builtin + providers) asRecommendationFolders without items — a fast call that serves as both the Discover skeleton and the row-toggle catalog.music/recommendations/items(provider,item_id) returns one row's items — this is where backend fetches live, so hidden rows cost nothing.1. Refactor — recommendations subcontroller
Default rows live in a
mass.music.recommendationssubcontroller backed by a registry of small source units (RecommendationSource), each exposing a row descriptor and an items builder — the same two-method shape the providers implement. Existing rows, their stableitem_ids and order are unchanged. Per-row failures are isolated: a throwing source or provider logs and yields an empty result instead of breaking the response. The rows call uses a short per-provider timeout (rows are contractually fast); the items call keeps the standard provider timeout.2. "Recently Played" fixes (support#5671)
user_initiated_only=True— playing three albums shows three albums.3.
enabled_by_defaultper rowEach row carries an
enabled_by_defaulthint (defaultTrue). Advanced/heavy or noisier rows ship off by default; the client hides them until the user opts in. Per-user visibility and ordering stay owned by the frontend'sdiscover.rowspreference (persists and syncs across clients), so the server stays stateless.4. Every provider implements the two methods
get_recommendations()(rows, no items, fast) andget_recommendation_items(item_id)are now the provider contract; the old bulkrecommendations()method is removed. Two provider shapes:@use_cachewithbase_class=RecommendationFolder).RecommendationPayloadMixingives them a persistent stale-while-revalidate cache with single-flight deduplication (concurrent cold calls trigger exactly one backend fetch, shielded from caller timeouts); rows derive from the cached payload with full section fidelity, items extract from the same payload. Total backend I/O is unchanged from before — one bulk fetch per TTL — while only the requested rows' items cross the wire.Also included:
<provider>://recommendations) reworked onto the same two methods; the user-visible browse tree is unchanged.metadata_recommendationstool mirrors the split: it returns row briefs (breaking:item_urisdropped — it could only be empty or force N fetches) and a newmetadata_recommendation_itemstool fetches one row's items.devis merged in, including porting Plex's new extended recommendations / "Mixes For You" (Plex: Add extended recommendations with "Mixes For You" support #3736) into the payload shape.Dependencies (companion PRs)
enabled_by_defaultto the sharedRecommendationFolder; released inmusic-assistant-models==1.1.164, which this PR pins.Types of changes
enhancementChecklist
pre-commit run --all-filespasses.pytestpasses, and tests have been added/updated undertests/where applicable.music-assistant/modelsis linked. — models#302music-assistant/frontendis linked. — frontend#2141Related issue