Skip to content

Load Discover recommendation rows on demand and improve Recently Played - #4487

Merged
marcelveldt merged 39 commits into
music-assistant:devfrom
chrisuthe:feat/recommendations-subcontroller
Jul 23, 2026
Merged

marcelveldt merged 39 commits into
music-assistant:devfrom
chrisuthe:feat/recommendations-subcontroller

Conversation

@chrisuthe

@chrisuthe chrisuthe commented Jun 29, 2026 •

Copy link
Copy Markdown
Member

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/recommendations returns every available row (builtin + providers) as RecommendationFolders 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.

Note: an earlier revision achieved partial laziness with a wanted row filter on the single bulk call. Per review feedback it was replaced by this full rows/items split, with the fetch-cost concern solved server-side (see §4): rows are static or served from a persistent cache, and per-row item fetches reuse the per-row/bulk caching each provider already had.

1. Refactor — recommendations subcontroller

Default rows live in a mass.music.recommendations subcontroller 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 stable item_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)

  • Rolls up to the container the user actually played (album / playlist / artist) instead of every constituent track, via user_initiated_only=True — playing three albums shows three albums.
  • Podcasts and audiobooks surface as their container (the show, the book), not the episode/chapter; a played episode credits its parent podcast so the show appears even from episode-only listening.
  • Two new granular rows: Recent Artists and Recent Tracks (with translations).

3. enabled_by_default per row

Each row carries an enabled_by_default hint (default True). 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's discover.rows preference (persists and syncs across clients), so the server stays stateless.

4. Every provider implements the two methods

get_recommendations() (rows, no items, fast) and get_recommendation_items(item_id) are now the provider contract; the old bulk recommendations() method is removed. Two provider shapes:

  • Static rows (kion_music, yandex_music, deezer, musicme, neteasecloudmusic, nicovideo, opensubsonic, qqmusic, nugs, zvuk_music, bandcamp, tunein, itunes_podcasts, smart_playlist, sonic_similarity, lastfm, musicbrainz): the rows call is a hardcoded descriptor list with zero backend I/O (local config/auth gates only); each row's items call does that row's dedicated fetch, keeping the caching the bulk method had (folder-level @use_cache with base_class=RecommendationFolder).
  • Atomic payload (ytmusic, apple_music, tidal, plex, soundcloud, bbc_sounds, audiobookshelf, yousee): the backend serves rows+items in one blob, so a shared RecommendationPayloadMixin gives 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 browse (<provider>://recommendations) reworked onto the same two methods; the user-visible browse tree is unchanged.
  • The fastmcp metadata_recommendations tool mirrors the split: it returns row briefs (breaking: item_uris dropped — it could only be empty or force N fetches) and a new metadata_recommendation_items tool fetches one row's items.
  • Per-provider tests assert the zero-I/O rows property, per-row fetch isolation, warm-cache-hit serving, and single-flight behavior.
  • Upstream dev is 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)

Types of changes

  • Enhancement to an existing feature — enhancement

Checklist

  • The code change is tested and works locally.
  • pre-commit run --all-files passes.
  • pytest passes, and tests have been added/updated under tests/ where applicable.
  • For changes to shared models, the companion PR in music-assistant/models is linked. — models#302
  • For changes affecting the UI, the companion PR in music-assistant/frontend is linked. — frontend#2141
  • I have read and complied with the project's AI Policy for any AI-assisted contributions.
  • I have raised a PR against the documentation repository targeting the main or beta branch as appropriate.

Related issue

@chrisuthe chrisuthe changed the title Feat/recommendations subcontroller Pull Recommendations to Subcontroller Jun 29, 2026
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.
@dmoo500

dmoo500 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.
@chrisuthe

Copy link
Copy Markdown
Member Author

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.

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.

@marcelveldt

Copy link
Copy Markdown
Member

Exactly 👍

@codspeed

codspeed Bot commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 11 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing chrisuthe:feat/recommendations-subcontroller (77a631a) with dev (75651d3)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@chrisuthe chrisuthe changed the title Pull Recommendations to Subcontroller Recommendations: subcontroller + Recently Played fixes + lazy rows/items delivery with central config Jul 13, 2026
@chrisuthe chrisuthe changed the title Recommendations: subcontroller + Recently Played fixes + lazy rows/items delivery with central config Recommendations: subcontroller + Recently Played fixes + lazy rows/items delivery Jul 14, 2026
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.
@chrisuthe
chrisuthe force-pushed the feat/recommendations-subcontroller branch from 7834e14 to 23fb8b3 Compare July 17, 2026 20:51
Comment thread music_assistant/controllers/music/controller.py
- 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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) and music/recommendations/items (per-row items) via a new mass.music.recommendations subcontroller.
  • Updated provider contracts from bulk recommendations() to get_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.

Comment thread music_assistant/controllers/music/recommendations/library.py
Comment thread music_assistant/providers/opensubsonic/sonic_provider.py
Comment thread music_assistant/providers/opensubsonic/sonic_provider.py
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).
Comment thread music_assistant/controllers/music/recommendations/controller.py
Comment thread music_assistant/controllers/music/controller.py Outdated
Comment thread music_assistant/controllers/music/recommendations/library.py
Comment thread music_assistant/models/recommendation_payload.py Outdated
Comment thread tests/models/test_recommendation_payload.py Outdated
Comment thread music_assistant/models/recommendation_payload.py Outdated
Comment thread music_assistant/models/recommendation_payload.py Outdated
Comment thread music_assistant/models/recommendation_payload.py
Comment thread music_assistant/providers/lastfm_recommendations/__init__.py
- 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.
@marcelveldt marcelveldt changed the title Recommendations: two-method delivery (rows + per-row items), subcontroller, Recently Played fixes Load Discover recommendation rows on demand and improve Recently Played Jul 23, 2026

@marcelveldt marcelveldt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Amazing work, @chrisuthe !

@chrisuthe

Copy link
Copy Markdown
Member Author

@dmoo500 - I believe this was blocking some of you work, should be good to go now!

@chrisuthe
chrisuthe deleted the feat/recommendations-subcontroller branch July 23, 2026 14:33
OzGav added a commit to music-assistant/music-assistant.io that referenced this pull request Jul 27, 2026
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
teancom added a commit to music-assistant/mobile-app that referenced this pull request Aug 6, 2026
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.
chrisuthe added a commit that referenced this pull request Aug 13, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants