Commit graph

5 commits

Author SHA1 Message Date
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
6e1a377f37 test(#945): run db_service_track_id against a real temp schema
The broad except->None in db_service_track_id would mask a column/join typo as "no match"
for every track (so export would silently say "nothing to export"). Adds a test that builds
a real tracks/artists sqlite schema and runs the ACTUAL query — confirms it returns the right
spotify_track_id/deezer_id column, case-insensitively, and None on no-match. Closes the one
integration gap the mocked unit tests left open.
2026-06-28 21:26:39 -07:00
BoulderBadgeDad
ede62824ad playlist export: resolver foundation for Spotify/Deezer targets (#945, increment 1)
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.
2026-06-28 21:04:47 -07:00
BoulderBadgeDad
d430dd8b71 #903: real source wiring for the export MBID waterfall
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.
2026-06-22 20:35:20 -07:00