Boulder: "all 50 tracks are discovered to Deezer already — it's not using any of that." Right —
the export only checked tracks.deezer_id (library) and ignored the IDs discovery already resolved
and stored in each mirrored track's extra_data. So tracks that were discovered+downloaded but not
separately enriched showed as "not on Deezer" and got dropped.
Adds a per-track waterfall for service export:
- service_id_from_extra_data(track, service): the id discovery already matched, read from
extra_data.matched_data.id — FREE (no API call) and reliable (it's the same id used to mirror
the track). Trusted only when discovered ON the target service (provider == service); a
wing_it_fallback (low-confidence guess) does NOT match here, so it falls through rather than
risk a wrong track in the export.
- resolve_service_track_ids(tracks, service): cache (extra_data) → library stored id → unmatched.
Reports from_cache / from_library / unmatched. _run_service_export now uses this instead of the
artist/title MBID-style resolver.
For Boulder's playlist this means all 50 resolve straight from the cache — full coverage, zero API
calls. (A live confident-search backfill for the genuinely-missing remainder is the optional next
step, gated + thresholded.)
9 new tests: extra_data id only when provider matches + wing_it excluded + bad-json/not-discovered
guards, the cache→library→unmatched waterfall with stat tallies, and _run_service_export resolving
straight from the cache end-to-end. 49 export tests green, ruff clean.
Ties the resolver + write clients into a working backend, reusing the ListenBrainz export's
resolve→push→store-target shape:
- _run_service_export(job, db, playlist_id, title, service, client, resolve_fn): resolves the
mirrored playlist's tracks to their stored service track IDs (id_key='service_track_id'),
guards "nothing matched", pushes via the injected write client, and stores the returned
playlist id as the export target so a re-export updates in place (idempotent, like LB #903).
Deps injected → unit-testable without a DB or live service.
- _run_playlist_export dispatches mode in {spotify, deezer} to it (builds the real client +
service resolver); the existing download/push (ListenBrainz/JSPF) flow is untouched.
- POST /api/playlists/<id>/export/service/<service> — distinct path so it can't collide with
the existing /export/listenbrainz route; validates the target, starts the background job,
returns {job_id} polled via the shared status endpoint.
5 orchestration tests (fake db/client/resolve_fn): success stores target + passes ids in order,
no-match → error with no push, client None → not-connected error, push failure surfaces the
client's error and stores nothing, re-export passes the existing target id. ruff clean.
Last piece: the modal options (Sync to Spotify / Deezer, gated on auth, unmatched count surfaced).