First piece of "export a mirrored playlist to Spotify/Deezer" (diegocade1). Reuses the exact
machinery the ListenBrainz/JSPF export already proves out, additively:
- resolve_playlist_tracks gains an `id_key` param (default "recording_mbid" → LB/JSPF callers
byte-for-byte unchanged). The dedup/stats/order logic is ID-agnostic; only the output field
name differs, so service export plugs in with id_key="service_track_id".
- export_sources gains db_service_track_id(artist, title, service) + build_service_resolve_fn —
text-matches a library track (same pattern as the MBID resolver) and returns its stored
spotify_track_id / deezer_id. Enrichment already pinned those IDs, so export is a lookup, not a
re-search — which is what makes the reverse direction reliable (no fuzzy guessing).
No schema change needed: get/set_playlist_export_target already key by service name, so Spotify/
Deezer targets store for free (idempotent re-export, like the LB #903 fix).
7 tests: LB default id_key unchanged, service id_key carries the id + unmatched handling, service→
column mapping, unknown-service/no-title guards, resolve_fn id+source. 38 export tests green,
ruff clean.
Remaining increments: Spotify write client (+ playlist-modify scope / re-auth), Deezer write via
the ARL gw-light gateway, the export-job branch + endpoint, the modal options.
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:
- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
create_or_update_playlist (update when we have a prior MBID, else create; falls back to
create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.
+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
Phase 4. core/exports/export_sources.py supplies the real I/O behind each waterfall source
and assembles resolve_fn: cache -> DB (tracks.musicbrainz_recording_id via text match) ->
file tag (UFID/musicbrainz_trackid) -> live MusicBrainz match_recording. Every source is
fail-safe (any error -> None -> fall through, export never breaks). A fresh non-cache hit is
written back to the persistent cache so the same song is free next export. Sources are
injectable; build_resolve_fn wiring (cache short-circuit + write-back) is unit-tested. 4 tests.
Phase 3. Additive backbone for the export job:
- mb_recording_cache table (IF NOT EXISTS) + core/exports/recording_mbid_cache.py: persistent
(artist,title)->recording_mbid cache, mirrors album_mbid_cache (lazy DB, error-degrades to
miss). The MusicBrainz tail is ~1 req/s, so a resolved MBID is remembered once and reused
across every export/playlist.
- core/exports/playlist_export.py: resolve_playlist_tracks(tracks, resolve_fn) — walks tracks,
dedups repeated songs within a run (resolve once), builds the ordered pseudo-playlist, tallies
live stats (resolved/unmatched/deduped/by_source). Pure (I/O injected via resolve_fn + progress
callback), so dedup + accounting are unit-tested with no DB/network. 5 tests.
No wiring into runtime yet; nothing existing touched except the additive table.
Phase 2. Add create_playlist(title, tracks, public) to the LB client: POST /playlist/create
for the MBID, then add tracks in batches of 100 (MAX_RECORDINGS_PER_ADD) via the item/add
endpoint so 1k-track playlists don't hit a single-request cap. Returns a result dict
{success, playlist_mbid, playlist_url, added, requested, error} and never raises — partial
add failures are reported honestly (playlist created, added count accurate). Extends the
existing token-auth client; additive. 4 mocked-network tests (batching, auth, failure).
Phase 1 of exporting mirrored playlists to ListenBrainz. Two pure, fully-tested seams,
zero runtime wiring yet (additive, no regression):
- core/exports/jspf_export.py: build_jspf(title, tracks) -> ({"playlist": {...}}, summary).
LB's POST /1/playlist/create requires every track to carry a string identifier
'https://musicbrainz.org/recording/<mbid>' (text-only tracks are rejected), so tracks
without a valid recording-MBID UUID are dropped and counted in the coverage summary.
- core/exports/mbid_resolver.py: resolve_recording_mbid(artist, title, sources) — the
cheapest-first waterfall (cache -> DB -> file tag -> MusicBrainz) as a pure function over
injected (label, fn) sources. Short-circuits expensive lookups, treats a raising source
as a miss (one flaky MB call can't fail the export), reports the resolving source label.
API spec confirmed against LB docs: POST /1/playlist/create, 'Authorization: Token <t>',
{"playlist": {"title", "track": [{"identifier": "<mb recording url>", title, creator, album}]}}.
13 tests.