Commit graph

5 commits

Author SHA1 Message Date
BoulderBadgeDad
efefdd64ff spotify export: clickable authorize link (popup-block safe) + endpoint pre-check tests
Double-checking the on-demand auth flow: the needs_auth handler called window.open() AFTER an
await, which breaks the user-gesture chain so browsers popup-block it — the user would see "approve
in the new tab" with no tab. Replaced with a clickable authorize link (a direct click is never
blocked).

Adds two endpoint tests via the Flask test client: Spotify export returns needs_auth + the
/auth/spotify/export url (and short-circuits before the DB) when the token lacks write scope, and
does NOT short-circuit when write scope is present. 10 service-export tests green, 64 script-integrity
green, ruff clean.
2026-06-29 08:35:25 -07:00
BoulderBadgeDad
9bb73c4cb4 fix(#945): Spotify backfill must disable cross-service search fallback
Double-checking the backfill logic found a real correctness bug. Spotify search_tracks defaults to
allow_fallback=True, so when Spotify is rate-limited or in free mode it returns iTunes/Deezer tracks
whose .id is an iTunes/Deezer id, NOT a Spotify id. The backfill took that .id as a Spotify track id
and would push wrong/garbage tracks into the exported Spotify playlist. The unit tests used fake
Track objects with hand-set ids, so they could never surface this cross-service contamination.

Fix: the Spotify backfill search now passes allow_fallback=False — real Spotify hits or nothing
(an unmatched track is left out, never replaced by a non-Spotify id). Deezer is unaffected: its
search fallback is query-only and stays within Deezer, so its ids are always Deezer ids.

Regression test asserts the Spotify backfill search is invoked with allow_fallback=False. 8
orchestration tests green, ruff clean.
2026-06-28 23:10:11 -07:00
BoulderBadgeDad
4743dfd644 playlist export: opt-in confident-search backfill for the unmatched tail (#945, increment 7)
Adds the third resolver stage for tracks the discovery cache + library can't resolve — a live
search of the target service, gated behind a "Match missing tracks" toggle so the API cost is opt-in.

The whole point is coverage WITHOUT the wrong-track risk, so it's a CONFIDENT match, not "search
and grab":
- search_service_track_id(artist, title, search_fn): searches the service, reranks via the existing
  relevance scorer (filter_and_rerank), and returns the top hit's id ONLY if it clears
  BACKFILL_MIN_SCORE (1.2 on the score_track scale). A wrong-artist hit (no 1.5x exact-artist boost,
  caps ~1.0) or a karaoke/cover (x0.05) can't clear the floor → None, and the track is left out
  rather than added wrong. search_fn injected → unit-testable without a live service.
- resolve_service_track_ids gains an optional search_id_fn: cache → library → search. Tallies
  from_search separately.
- _run_service_export builds the search fn from the service's metadata search client only when
  job['backfill'] is set; the endpoint reads `backfill` from the body; the modal adds the toggle and
  the status line shows "(N matched live)".

Store-back of confident matches deferred: a mirrored-only track may have no library row to write to,
so persisting needs the track→library mapping — a follow-up, not correctness.

9 new tests incl. the safety ones: wrong-artist rejected, karaoke/cover rejected, real-over-cover
picked, fail-safe on search error, and the cache→library→search waterfall + toggle wiring (on/off).
28 export/orchestration tests green, 64 script-integrity green, ruff clean.
2026-06-28 23:02:08 -07:00
BoulderBadgeDad
37c8b06a27 playlist export: resolve IDs from the discovery cache first (#945, increment 6)
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.
2026-06-28 21:39:39 -07:00
BoulderBadgeDad
465a77f01d playlist export: Spotify/Deezer export job + endpoint (#945, increment 4)
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).
2026-06-28 21:20:06 -07:00