soulsync/tests/exports/test_export_sources.py
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

90 lines
3 KiB
Python

"""Export source wiring (#903): waterfall order + cache write-back.
build_resolve_fn assembles cache -> DB -> file -> MusicBrainz and writes a fresh
(non-cache) hit back to the cache. Pins: a cache hit short-circuits everything and is
NOT re-written; a DB/MB hit IS written back; misses fall through; the resolving label is
returned.
"""
from __future__ import annotations
from core.exports.export_sources import build_resolve_fn
from core.exports.mbid_resolver import SRC_CACHE, SRC_DB, SRC_MUSICBRAINZ
MBID = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
def _wire(db=None, file=None, mb=None, cache=None):
recorded = {}
store = dict(cache or {})
fn = build_resolve_fn(
db_fn=lambda a, t: (db or {}).get((a, t)),
file_fn=lambda a, t: (file or {}).get((a, t)),
mb_fn=lambda a, t: (mb or {}).get((a, t)),
cache_lookup=lambda k: store.get(k),
cache_record=lambda k, m: recorded.__setitem__(k, m) or True,
)
return fn, recorded
def test_cache_hit_short_circuits_and_is_not_rewritten():
from core.exports.mbid_resolver import normalize_key
fn, recorded = _wire(
cache={normalize_key("A", "T"): MBID},
db={("A", "T"): "should-not-reach"},
)
mbid, label = fn("A", "T")
assert (mbid, label) == (MBID, SRC_CACHE)
assert recorded == {} # cache hit -> no write-back
def test_db_hit_is_written_back_to_cache():
from core.exports.mbid_resolver import normalize_key
fn, recorded = _wire(db={("A", "T"): MBID})
mbid, label = fn("A", "T")
assert (mbid, label) == (MBID, SRC_DB)
assert recorded == {normalize_key("A", "T"): MBID} # fresh hit cached for next time
def test_falls_through_to_musicbrainz_and_caches():
fn, recorded = _wire(db={}, file={}, mb={("A", "T"): MBID})
mbid, label = fn("A", "T")
assert (mbid, label) == (MBID, SRC_MUSICBRAINZ)
assert list(recorded.values()) == [MBID]
def test_all_miss_returns_none_and_no_write():
fn, recorded = _wire()
assert fn("A", "T") == (None, None)
assert recorded == {}
# ── service track-id resolver (#945 export to Spotify/Deezer) ──
from core.exports.export_sources import (
db_service_track_id,
build_service_resolve_fn,
_SERVICE_ID_COLUMNS,
)
def test_service_id_column_mapping():
assert _SERVICE_ID_COLUMNS == {'spotify': 'spotify_track_id', 'deezer': 'deezer_id'}
def test_db_service_track_id_unknown_service_is_none():
assert db_service_track_id('A', 'X', 'tidal') is None
assert db_service_track_id('A', 'X', '') is None
def test_db_service_track_id_no_title_is_none():
assert db_service_track_id('A', '', 'spotify') is None
def test_build_service_resolve_fn_returns_id_and_source(monkeypatch):
import core.exports.export_sources as es
monkeypatch.setattr(es, 'db_service_track_id',
lambda a, t, s: 'spid-99' if t == 'Hit' else None)
fn = build_service_resolve_fn('spotify')
assert fn('Artist', 'Hit') == ('spid-99', 'library')
assert fn('Artist', 'Miss') == (None, None)