Migrate playlists between providers - #5926
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Pull request overview
Adds managed playlist migration between Music Assistant and streaming providers.
Changes:
- Introduces confidence-based cross-provider track matching.
- Preserves playlist order and duplicates with batched writes and migration reports.
- Adds API support and comprehensive migration tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
music_assistant/constants.py |
Bumps the API schema version. |
music_assistant/helpers/compare.py |
Adds track-match confidence evaluation. |
music_assistant/controllers/music/media/tracks.py |
Resolves and enriches provider mappings. |
music_assistant/controllers/music/media/playlists.py |
Implements managed playlist migration. |
tests/helpers/test_compare.py |
Tests confidence classification. |
tests/controllers/music/test_tracks.py |
Tests provider matching and enrichment. |
tests/controllers/music/test_playlist_migration.py |
Tests migration, batching, reports, order, and duplicates. |
Suppressed comments (1)
music_assistant/controllers/music/media/tracks.py:727
- [PROBLEM] Hydrating every search result here creates an N+1 API pattern—up to 25
get_trackcalls per artist query, followed by per-candidate album lookups—and the five-track semaphore limits concurrency but not request volume; score the returnedTrackobjects first and hydrate only a bounded finalist set (or use a provider bulk endpoint).
candidate = await self.get_provider_item(
search_result.item_id,
search_result.provider,
fallback=search_result,
)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/tracks.py:851
- [PROBLEM] Marking a domain as processed before lookup means a miss, ambiguity, or temporary failure on the first allowed instance prevents trying another instance of the same service, so enrichment can omit a mapping that another configured account could resolve; rely on
existing_domains, which is already updated after a successful match.
if provider.domain in processed_domains or provider.domain in existing_domains:
continue
processed_domains.add(provider.domain)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/tracks.py:723
- [CRITICAL] A library-derived
LIKELY/LOOSEmapping returns here before provider search, so policies that permit substitutions can select an alternate release even when an exact release is available; retain this as a fallback candidate and continue searching, short-circuiting onlyEXACT.
if confidence >= minimum_confidence and (
candidate_mapping := self._get_provider_mapping(
mapped_candidate,
provider,
)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/playlists.py:438
- [PROBLEM] The documented domain form is not resolved within the current user's provider scope:
mass.get_provider("qobuz")returns the first globally loaded instance, so if that instance is filtered out while another Qobuz instance is allowed, this rejects a valid destination; resolve instance/domain fromself.mass.music.providers(preferring an exact instance ID) before validating capabilities.
provider = self.mass.get_provider(destination_provider)
if not provider or not isinstance(provider, MusicProvider):
raise ProviderUnavailableError(f"Provider {destination_provider} is not available")
if provider.domain != "builtin" and provider.instance_id not in {
item.instance_id for item in self.mass.music.providers
}:
raise ProviderUnavailableError(f"Provider {destination_provider} is not available")
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
music_assistant/helpers/compare.py:515
- [CRITICAL] Hydrated album evidence is not passed to
_track_explicit, so tracks whosealbumis anItemMappingignore the full albums' explicit flags and can accept a clean/explicit substitution; usebase_albumandcompare_album_itemas the album-level fallback when extracting explicitness.
base_explicit = _track_explicit(base_item)
compare_explicit_value = _track_explicit(compare_item)
music_assistant/helpers/compare.py:72
- [CRITICAL] A bare featured credit followed by version metadata, such as
Song feat. Guest (Radio Edit), capturesGuest (Radio Editas the artist, so it conflicts with a provider's structuredGuestcredit and rejects the same recording; stop the capture at an opening version bracket as well as its closing bracket.
_FEATURED_ARTISTS_PATTERN = re.compile(
r"(?:\(|\[)?\b(?:feat(?:uring)?|ft)\.?\s+(.+?)(?=\)|\]| - |$)",
re.IGNORECASE,
)
music_assistant/controllers/music/media/playlists.py:474
- [CRITICAL] The selected source mapping can be outside the current user's provider filter because
_select_provider_idfalls back to the first mapping, and adding it here explicitly authorizes the deferred task to read that provider; reject non-builtin source instances absent fromavailable_providersbefore snapshotting the task scope.
source_provider, source_item_id = self._select_provider_id(source_playlist)
allowed_provider_instances = {item.instance_id for item in self.mass.music.providers}
allowed_provider_instances.update(
(source_provider, provider.instance_id),
)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
music_assistant/controllers/music/controller.py:669
- [PROBLEM] Resolving a domain before applying the provider scope breaks the documented domain form with multiple accounts:
mass.get_provider("qobuz")may select a filtered-outqobuz_1, and this returns no results even when allowedqobuz_2is available; filter all matching instances first, then prefer an exact instance ID or an allowed domain match.
provider = self.mass.get_provider(
provider_instance_id_or_domain,
provider_type=MusicProvider,
)
if not provider:
5123756 to
1040f14
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/tracks.py:1107
- [CRITICAL] This optional album lookup omits
ResourceTemporarilyUnavailable, although providers such as Qobuz and Apple Music use that typed error for transient API failures; any non-EXACTcandidate then aborts matching and is skipped instead of retaining its track-level confidence. Catch it alongside the other transient failures and cover the typed exception in the fallback test.
ProviderUnavailableError,
music_assistant/controllers/music/media/playlists.py:579
- [PROBLEM] This runs five whole-track resolutions concurrently, and each can search the same rate-limited provider before the shared failure set is updated, so one migration creates a five-wide API burst and the “stop after provider failure” guard cannot suppress the rest of that batch. Serialize lookups per provider or use provider-scoped workers/semaphores so only independent providers run concurrently (precedent: server#3171).
resolved_items.extend(
await asyncio.gather(*(resolve_track(key, track) for key, track in track_batch))
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/playlists.py:520
- [CRITICAL] The static-playlist check only runs before this deferred task is queued, so a playlist that becomes dynamic while waiting is still snapshotted here; revalidate
is_dynamicafter reloading the playlist in the handler.
source_playlist = await self.get_library_item(source_playlist_id)
music_assistant/controllers/music/media/playlists.py:540
- [CRITICAL] Non-track entries are reported here but never included in
counts["total"],counts["skipped"], orskipped_tracks, so a mixed static playlist can finish with “Migrated N of N” while its radios/episodes are absent from the final skipped-track report; either reject non-track playlists during validation or carry these entries into the migration totals and report.
if isinstance(item, Track):
source_tracks.append(item)
continue
report_current_task_failure(
f"{item.name}: {item.media_type.value} items are not supported"
)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
music_assistant/controllers/music/media/playlists.py:481
- [CRITICAL] Static plugin-owned playlists are exposed as migratable by the companion UI but are rejected here (and again by the handler's
MusicProvidercheck), even thoughPluginProvider.get_playlist_tracksis the supported playlist source interface and static smart playlists use it; accept plugin sources while treating their attached mappings as untrusted for exact matching.
source_provider_obj = self.mass.get_provider(
source_provider,
provider_type=MusicProvider,
)
if not source_provider_obj or source_provider_obj.domain != "builtin":
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
music_assistant/helpers/compare.py:72
- [PROBLEM] The new credit matcher omits the
with <artist>form that the existing title parser treats as a featured credit (tests/helpers/test_helpers.py:192-195), so a track such asSong (with Alice)cannot match the same recording when another provider supplies Alice structurally; use one shared featured-credit normalizer for both title comparison and credit extraction, preserving the existing title-word exceptions.
_FEATURED_ARTISTS_PATTERN = re.compile(
r"(?:\(|\[)?\b(?:feat(?:uring)?|ft)(?:(?:\.|:)\s*|\s+)"
r"(.+?)(?=\s*(?:\(|\[|\)|\]| - |$))",
re.IGNORECASE,
music_assistant/controllers/music/controller.py:694
- [CRITICAL] When an explicitly scoped instance becomes unavailable after the migration starts, filtering it out here turns the outage into an empty successful search, so all affected tracks are reported as
no acceptable matchinstead of a provider failure; raise the existing temporary-unavailable error for a requested captured instance and reserve an empty result for a completed search with no hits.
if provider is None:
return SearchResults()
What does this implement/fix?
Copying a playlist between music services currently requires a manual export/import workflow.
Related issue (if applicable):
Types of changes
bugfixnew-featureenhancementnew-providerbreaking-changerefactordocumentationmaintenancecidependenciesChecklist
pre-commit run --all-filespasses.pytestpasses, and tests have been added/updated undertests/where applicable.music-assistant/modelsis linked.