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.
72 lines
2.8 KiB
Python
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}
|