soulsync/tests/exports/test_jspf_export.py
BoulderBadgeDad fb9d88ea6a #903: playlist export to ListenBrainz — pure cores (JSPF builder + MBID resolver)
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.
2026-06-22 20:25:44 -07:00

72 lines
2.8 KiB
Python

"""JSPF builder for ListenBrainz playlist export (#903).
LB's create-playlist requires every track to carry a recording-MBID identifier; text-only
tracks are rejected. These pin the shape (top-level {"playlist": {...}}, string identifier
in the exact MB recording URL form), that tracks without a valid MBID are dropped, and that
the coverage summary counts included vs skipped.
"""
from __future__ import annotations
from core.exports.jspf_export import MB_RECORDING_PREFIX, build_jspf, is_valid_recording_mbid
MBID_A = "e8f9b188-f819-4e43-ab0f-4bd26ce9ff56"
MBID_B = "8f3471b5-7e6a-4c1f-9c1a-2b2b2b2b2b2b"
def test_valid_mbid_check():
assert is_valid_recording_mbid(MBID_A) is True
assert is_valid_recording_mbid("not-a-uuid") is False
assert is_valid_recording_mbid("") is False
assert is_valid_recording_mbid(None) is False
def test_top_level_shape_and_identifier_format():
jspf, summary = build_jspf("My Playlist", [
{"recording_mbid": MBID_A, "title": "Gold", "artist": "Spandau Ballet", "album": "True"},
])
assert set(jspf.keys()) == {"playlist"}
pl = jspf["playlist"]
assert pl["title"] == "My Playlist"
assert len(pl["track"]) == 1
t = pl["track"][0]
# identifier is a STRING in the exact MB recording form (per the LB/JSPF spec)
assert t["identifier"] == f"{MB_RECORDING_PREFIX}{MBID_A}"
assert isinstance(t["identifier"], str)
assert t["title"] == "Gold"
assert t["creator"] == "Spandau Ballet" # artist -> creator
assert t["album"] == "True"
assert summary == {"total": 1, "included": 1, "skipped": 0}
def test_tracks_without_valid_mbid_are_dropped():
jspf, summary = build_jspf("P", [
{"recording_mbid": MBID_A, "title": "Keep"},
{"recording_mbid": "", "title": "No MBID"},
{"recording_mbid": "garbage", "title": "Bad MBID"},
{"title": "Missing key entirely"},
])
assert [t["title"] for t in jspf["playlist"]["track"]] == ["Keep"]
assert summary == {"total": 4, "included": 1, "skipped": 3}
def test_order_is_preserved():
jspf, _ = build_jspf("P", [
{"recording_mbid": MBID_A, "title": "first"},
{"recording_mbid": MBID_B, "title": "second"},
])
assert [t["title"] for t in jspf["playlist"]["track"]] == ["first", "second"]
def test_optional_fields_omitted_when_absent():
jspf, _ = build_jspf("P", [{"recording_mbid": MBID_A}])
t = jspf["playlist"]["track"][0]
assert "title" not in t and "creator" not in t and "album" not in t
def test_creator_and_title_defaults():
jspf, summary = build_jspf("", [], creator="SoulSync")
assert jspf["playlist"]["title"] == "SoulSync Export" # blank -> default
assert jspf["playlist"]["creator"] == "SoulSync"
assert jspf["playlist"]["track"] == []
assert summary == {"total": 0, "included": 0, "skipped": 0}