Sync Apple Music playlists into a beets library via playlistmanager
  • Python 98.1%
  • Shell 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-07 22:33:36 +01:00
beetsplug/appleplaylists fix match commands with beets queries that contain spaces not matching anything 2026-09-07 22:33:36 +01:00
ci add arch package 2026-09-06 15:47:15 +01:00
packaging/arch add arch package 2026-09-06 15:47:15 +01:00
tests fix match commands with beets queries that contain spaces not matching anything 2026-09-07 22:33:36 +01:00
.gitignore Scaffold beets-appleplaylists: sync Apple Music playlists via playlistmanager 2026-08-09 20:13:34 +00:00
.python-version Scaffold beets-appleplaylists: sync Apple Music playlists via playlistmanager 2026-08-09 20:13:34 +00:00
AGENTS.md document usage of fj CLI in AGENTS.md 2026-09-06 11:34:35 +01:00
pipeline.yaml add arch package 2026-09-06 15:47:15 +01:00
pyproject.toml fix match commands with beets queries that contain spaces not matching anything 2026-09-07 22:33:36 +01:00
README.md add arch package 2026-09-06 15:47:15 +01:00
requirements-integration.txt Fix sync and local workflows (#44 #45 #46 #47 #48 #49 #50) 2026-09-06 12:16:06 +01:00
SPEC.md fix match commands with beets queries that contain spaces not matching anything 2026-09-07 22:33:36 +01:00
uv.lock Plugin: migrate Apple Music access to gateway (#12-16) 2026-08-16 21:12:54 +01:00

beets-appleplaylists

Sync configured Apple Music playlists into existing playlists in a beets library. apple-music-gateway owns Apple authentication and Apple API access; this plugin owns matching and local state; beets-playlistmanager owns local playlist persistence and rendering.

Apple Music is authoritative for playlist contents and order. Synchronization is one-way: the plugin never writes to Apple Music, creates or renames local playlists, changes playlist metadata, or writes audio-file tags.

SPEC.md is the source of truth for behavior.

Requirements

  • beets-playlistmanager, installed and configured alongside this plugin
  • a deployed apple-music-gateway
  • a gateway service token
  • an Apple Music session linked through the gateway web UI

The mapped playlistmanager targets must exist before synchronization.

Configuration

plugins: playlistmanager appleplaylists

playlistmanager:
  database: playlist.db

appleplaylists:
  apple_music:
    service_url: https://apple-music-gateway.example
    service_token: your-gateway-bearer-token
    playlists:
      - id: "p.6xZa3e0hYED62mz"  # Apple library-playlist ID
        name: "My Playlist"      # existing local playlistmanager name

service_url and service_token are read only from YAML; there are no environment-variable credential fallbacks. Keep the bearer token out of source control. Playlist IDs and local names must be non-empty and unique. An empty playlists: [] is a successful offline sync no-op.

The configured local name—not the Apple display name—selects the target. The plugin validates all selected mappings and local targets before fetching or mutating data.

Gateway setup

Deploy coop/apple-music-gateway with its Apple developer credentials, HTTPS base URL, persistent gateway database, and SERVICE_TOKEN. Deployments are published by the gateway repository's Concourse pipeline. Register its HTTPS callback URL with the MusicKit Services ID, then visit the gateway web UI to link the Apple Music session. Apple developer keys and the Apple user token remain on the gateway and are never stored by this plugin.

The SERVICE_TOKEN value must match appleplaylists.apple_music.service_token on the beets host.

Commands

All commands are under beet apple.

status

beet apple status

Reports gateway reachability, service-token acceptance, Apple-session presence and validity, and overall readiness. A non-ready status exits non-zero and prints the gateway URL used to link the Apple session.

list-playlists

beet apple list-playlists

Fetches every user-created Apple Music library playlist and shows its ID, display name, and whether it is managed by the current configuration. This is a read-only remote operation and fails rather than falling back to stale data.

list-tracks

beet apple list-tracks

Reads only the plugin state database and active beets library, so it works while the gateway is unavailable. It lists each Apple library track with a current managed-playlist membership once, including Apple metadata, local playlist names, associated beets items, resolution status, and ISRC conflicts.

sync [LOCAL_PLAYLIST_NAME]

beet apple sync
beet apple sync "My Playlist"

With no name, fetches and validates every configured mapping before mutation. With a name, fetches only the mapping targeting that existing local playlist. An unconfigured or missing target is an error.

The command replaces playlist contents with matched beets item IDs while preserving Apple order and duplicate occurrences. Unmatched tracks are omitted and reported as MISSING without failing the sync. Full syncs may prune stale plugin membership rows after a complete remote and local view; named syncs never prune other playlists. Track history and beets associations are retained. Regenerate configured playlistmanager outputs separately with beet playlist sync.

match

beet apple match
beet apple match i.apple-library-track-id 'id:123'

The no-argument form works offline from plugin state. It walks unresolved current tracks one at a time and accepts a beets query, a one-run skip, or a permanent skip. Each non-dry-run decision is committed immediately, so an interrupted run resumes from the remaining tracks.

The two-argument form requires the beets query to return exactly one item. It replaces the track's existing association without confirmation, clears a permanent skip, and copies the Apple ISRC into the beets isrc field only when the local value is empty.

Dry run

Use the global flag with either mutating operation:

beet apple --dry-run sync
beet apple --dry-run sync "My Playlist"
beet apple --dry-run match
beet apple --dry-run match i.apple-library-track-id 'id:123'

Dry-run mode still validates, fetches, matches, and reports proposed changes, but it does not modify the plugin SQLite database, beets fields, or playlistmanager contents. Interactive dry-run choices are discarded.

Local state

The plugin stores appleplaylists.db beside the active beets library.db. It contains retained Apple track metadata, resolution state, and ordered current memberships for configured Apple playlists. Duplicate occurrences are stored by position. Historical track rows remain after memberships disappear.

Schema changes use numbered, transactional, forward-only migrations. A newer unsupported schema fails clearly; the plugin never deletes and rebuilds the state database automatically.

Matching policy

Automatic resolution uses this precedence:

  1. an existing apple_music_id association;
  2. exactly one local ISRC candidate;
  3. exactly one exact normalized artist/title candidate within the duration tolerance when both durations are present;
  4. otherwise unmatched or ambiguous for manual resolution.

Local metadata wins. A non-empty local ISRC is never overwritten, and conflicts are reported separately from resolution state.

Security

Treat the gateway service token as a secret. The plugin never prints it. Apple developer credentials and Apple session data belong only on the gateway; the plugin stores neither.

Development and integration tests

Run the unit suite with uv sync --extra dev followed by uv run pytest -q. The real playlistmanager integration suite uses the public PlaylistStore API from beets-playlistmanager 0.1.0, pinned to revision 4726c63306d9da23bcc0ce0e6aada0629ba2ba97 in requirements-integration.txt. To install it and run the complete suite:

uv sync --extra dev
uv pip install -r requirements-integration.txt
uv run pytest -q

Without that dependency, only tests/test_playlistmanager_integration.py is skipped. Integration tests create temporary beets libraries, playlist databases, and BEETSDIR directories; all Apple data is supplied locally without network calls. They cover WAL snapshots, dry-run/execution parity, duplicate occurrences, metadata preservation, transactional failure, and recovery.

Database contention is retried up to three attempts, with 50 ms and 100 ms backoff. Each attempt may also wait for the store's SQLite busy timeout (five seconds in the supported playlistmanager revision). Schema, constraint, permission, and persistent I/O failures are not retried. Errors identify the store and operation. Memberships and resolution decisions for one playlist commit together; separate stores can still complete partially, and a failed full sync leaves global pruning for the next successful run.

Arch Linux releases

pipeline.yaml builds the beets plugin as beets-appleplaylists-VERSION-1-any.pkg.tar.zst and publishes it to the coop Arch registry. It uses the same arch repository group and ((coop-forgejo.token)) credential as coop/arch. The token needs repository read access and package write access.

Install or update the pipeline in the coop team (this version of fly takes --team after the subcommand):

fly -t ci set-pipeline --team=coop -p beets-appleplaylists-release -c pipeline.yaml --check-creds -n
fly -t ci unpause-pipeline --team=coop -p beets-appleplaylists-release

To release:

  1. Update project.version in pyproject.toml, run uv lock, and commit and push the release source, including the packaging and CI files.

  2. Tag that commit with the matching stable version and push the tag:

    git tag v1.2.3
    git push origin v1.2.3
    

The Git resource matches v* tags; the job processes every detected version serially. The build requires a stable vMAJOR.MINOR.PATCH tag matching the Python version. Untagged commits, mismatched versions, and prerelease tags cannot publish. Local tags only become visible to Concourse after they are pushed. Apply pipeline configuration changes with set-pipeline before releasing; the build scripts and PKGBUILD come from the tagged commit.

The task builds in archlinux:base-devel as an unprivileged user, runs the unit tests, installs the resulting package, and checks beet apple --help with an isolated BEETSDIR. The playlistmanager integration tests skip when that separately distributed dependency is absent. Publishing runs only after these checks pass. Uploads retry transient failures, but existing versions are never deleted or replaced; a duplicate upload fails. Release a new version for a correction. Forgejo manages the repository database and signatures.

On an Arch host already configured for the coop/arch pacman repository:

sudo pacman -Syu beets-appleplaylists

Install beets-playlistmanager separately and configure it as described above; it is listed as an optional pacman dependency because it is not supplied by this pipeline, but is required for synchronization. Apple authentication and the gateway remain separate services. No license has been declared in this project, so the PKGBUILD does not invent one.

To test just the package build in Concourse, use a clean local checkout with a matching release tag (the output directory is outside the checkout):

fly -t ci execute --team=coop -c ci/build-package.yaml \
  -i source=. -o package=/tmp/beets-appleplaylists-package

This executes only the build task and does not upload to Forgejo.