Skip to content

Migrate playlists between providers - #5926

Open
marcelveldt wants to merge 33 commits into
devfrom
marcelveldt-playlist-migration
Open

Migrate playlists between providers#5926
marcelveldt wants to merge 33 commits into
devfrom
marcelveldt-playlist-migration

Conversation

@marcelveldt

@marcelveldt marcelveldt commented Aug 23, 2026

Copy link
Copy Markdown
Member

What does this implement/fix?

Copying a playlist between music services currently requires a manual export/import workflow.

  • Migrate static playlists to Music Assistant or another streaming provider.
  • Prefer exact releases, then same-recording or best-effort substitutes according to the selected policy.
  • Reuse library mappings without adding to or changing library items.
  • Preserve playlist order, retain duplicates where supported, report unsupported repeats, batch provider writes, and report substitutions or skipped tracks through the managed task.

Related issue (if applicable):

  • N/A

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • New music/player/metadata/plugin provider — new-provider
  • Breaking change (fix or feature that would cause existing functionality not to work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — documentation
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

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.
  • For changes affecting the UI, the companion PR is Migrate playlists between providers frontend#2614.
  • 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.

Copilot AI balanced review requested due to automatic review settings August 23, 2026 09:41
@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 11 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing marcelveldt-playlist-migration (b47c9e2) with dev (fcc484d)2

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.

  2. No successful run was found on dev (e0fe786) during the generation of this report, so fcc484d was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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

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_track calls per artist query, followed by per-candidate album lookups—and the five-track semaphore limits concurrency but not request volume; score the returned Track objects 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.

Comment thread music_assistant/controllers/music/media/tracks.py Outdated
Comment thread music_assistant/controllers/music/media/tracks.py Outdated
Comment thread music_assistant/helpers/compare.py
Copilot AI review requested due to automatic review settings August 23, 2026 09:56

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comment thread music_assistant/controllers/music/media/playlists.py
Comment thread music_assistant/controllers/music/media/playlists.py
Comment thread music_assistant/controllers/music/media/playlists.py Outdated
Comment thread music_assistant/helpers/compare.py Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 10:14

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

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)

Comment thread music_assistant/controllers/music/media/playlists.py
Copilot AI review requested due to automatic review settings August 23, 2026 10:29

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

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/LOOSE mapping 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 only EXACT.
                if confidence >= minimum_confidence and (
                    candidate_mapping := self._get_provider_mapping(
                        mapped_candidate,
                        provider,
                    )

Comment thread music_assistant/helpers/compare.py
Comment thread music_assistant/helpers/compare.py Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 10:42

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

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread music_assistant/controllers/music/media/tracks.py Outdated
Comment thread music_assistant/helpers/compare.py Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 10:56

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

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 from self.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")

Comment thread music_assistant/helpers/compare.py Outdated
Comment thread music_assistant/controllers/music/media/tracks.py
Copilot AI review requested due to automatic review settings August 23, 2026 11:13

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

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 whose album is an ItemMapping ignore the full albums' explicit flags and can accept a clean/explicit substitution; use base_album and compare_album_item as 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), captures Guest (Radio Edit as the artist, so it conflicts with a provider's structured Guest credit 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_id falls back to the first mapping, and adding it here explicitly authorizes the deferred task to read that provider; reject non-builtin source instances absent from available_providers before 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),
        )

Copilot AI review requested due to automatic review settings August 23, 2026 11:26
@OzGav OzGav added this to the 2.11.0 milestone Aug 23, 2026

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

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-out qobuz_1, and this returns no results even when allowed qobuz_2 is 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:

Comment thread music_assistant/controllers/music/media/playlists.py Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 11:38

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

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread music_assistant/controllers/music/media/playlists.py
Copilot AI review requested due to automatic review settings August 23, 2026 15:19
@marcelveldt
marcelveldt force-pushed the marcelveldt-playlist-migration branch from 5123756 to 1040f14 Compare August 23, 2026 15:19

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

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment thread music_assistant/controllers/music/media/tracks.py
Copilot AI review requested due to automatic review settings August 23, 2026 15:38

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

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-EXACT candidate 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))

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

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_dynamic after 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"], or skipped_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"
            )

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

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 MusicProvider check), even though PluginProvider.get_playlist_tracks is 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":

Comment thread music_assistant/controllers/music/controller.py
Comment thread music_assistant/controllers/music/media/tracks.py
Comment thread music_assistant/controllers/music/media/playlists.py Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 16:48

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

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment thread music_assistant/controllers/music/media/tracks.py
Copilot AI review requested due to automatic review settings August 23, 2026 17:09

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

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 as Song (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 match instead 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()

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

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

@marcelveldt
marcelveldt marked this pull request as ready for review August 23, 2026 17:49
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.

3 participants