"""Playlist import: link resolution, ordering, and the m3u Navidrome reads.
The resolvers are exercised against synthetic payloads rather than the live
sites. Two reasons: a test that fails when Spotify has a bad afternoon is not
testing this code, and the failure that actually matters here is the *shape*
changing -- which is precisely what a fixture can assert and a live fetch
cannot.
Run standalone: python tests/test_playlist.py
"""
import asyncio, json, os, sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("MUSE_AUTH", "off")
os.environ.setdefault("MUSE_DB", "dev/playlist.db")
os.environ.setdefault("MUSE_LIBRARY_DIR", "dev/pl-library")
for f in ("dev/playlist.db", "dev/playlist.db-wal", "dev/playlist.db-shm"):
if os.path.exists(f):
os.remove(f)
import app # noqa: E402
app.init_db()
app.LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
ok = fail = 0
def check(name, cond, detail=""):
global ok, fail
if cond:
ok += 1
print(f" PASS {name}")
else:
fail += 1
print(f" FAIL {name} {detail}")
SPOTIFY_PAGE = (
'")
APPLE_PAGE = (
'")
def fake_fetch(body):
async def _f(url):
return body
return _f
def make_playlist(pid, name, rows):
"""rows: (seq, artist, title, state, imported_path)"""
with app.db() as c:
c.execute("INSERT OR REPLACE INTO playlists (id,name,source,"
"source_url,tracks_total) VALUES (?,?,?,?,?)",
(pid, name, "spotify", "https://x", len(rows)))
for seq, artist, title, state, path in rows:
c.execute(
"INSERT INTO jobs (id,state,detail,artist,title,"
"imported_path,playlist_id,playlist_seq) "
"VALUES (?,?,?,?,?,?,?,?)",
(f"{pid}-{seq}", state, "", artist, title, path, pid, seq))
def main() -> int:
print("\nlink routing")
for url, want in [
("https://open.spotify.com/playlist/abc123", "spotify"),
("https://open.spotify.com/intl-pt/playlist/abc123", "spotify"),
("https://music.apple.com/us/playlist/hits/pl.abc-123", "apple"),
("https://www.deezer.com/en/playlist/1313621735", "deezer"),
]:
matched = ("spotify" if app.SPOTIFY_URL.search(url)
else "apple" if app.APPLE_URL.search(url)
else "deezer" if app.DEEZER_URL.search(url) else "none")
check(f"{want} link routes to {want}", matched == want, f"got {matched}")
print("\nresolvers, against a fixed payload shape")
real = app._fetch
try:
app._fetch = fake_fetch(SPOTIFY_PAGE)
name, tracks = asyncio.run(app._spotify_playlist("playlist", "x"))
check("spotify name", name == "Late Night", name)
check("spotify pulls artist from subtitle",
tracks[0] == ("Tory Lanez", "The Color Violet"), tracks[:1])
app._fetch = fake_fetch(APPLE_PAGE)
name, tracks = asyncio.run(app._apple_playlist("playlist", "pl.x", "u"))
check("apple name", name == "Heavy Rotation", name)
check("apple pulls artist from artistName",
("Justin Bieber", "YUKON") in tracks, tracks)
check("apple de-duplicates repeated entries", len(tracks) == 2, tracks)
# The failure that actually matters: they changed the page.
app._fetch = fake_fetch("nothing useful here")
for fn, label in ((app._spotify_playlist, "spotify"),
(app._apple_playlist, "apple")):
try:
asyncio.run(fn("playlist", "x", "u") if label == "apple"
else fn("playlist", "x"))
check(f"{label} shape change is reported", False, "no raise")
except app.PlaylistUnavailable as e:
check(f"{label} shape change names the resolver",
"needs updating" in str(e), str(e))
finally:
app._fetch = real
print("\nthe m3u Navidrome reads")
lib = app.LIBRARY_DIR
(lib / "Tory Lanez").mkdir(parents=True, exist_ok=True)
(lib / "Don Toliver").mkdir(parents=True, exist_ok=True)
a = lib / "Tory Lanez" / "The Color Violet.flac"; a.write_text("x")
b = lib / "Don Toliver" / "No Pole.flac"; b.write_text("x")
make_playlist("p1", "Late Night", [
(1, "Don Toliver", "No Pole", "imported", str(b)),
(0, "Tory Lanez", "The Color Violet", "imported", str(a)),
(2, "Nobody", "Missing", "failed", None),
])
dest = app.write_playlist("p1")
text = dest.read_text()
check("m3u is written", dest and dest.exists(), dest)
check("declares the playlist name for Navidrome",
"#PLAYLIST:Late Night" in text, text)
check("uses Navidrome's mount, not muse's",
"/music/Tory Lanez/The Color Violet.flac" in text
and str(lib) not in text, text)
check("keeps the playlist's own order, not insertion order",
text.index("The Color Violet") < text.index("No Pole"), text)
check("omits tracks that never imported", "Missing" not in text, text)
print("\nedge cases")
make_playlist("p2", "Nothing Landed Yet", [
(0, "A", "B", "searching", None)])
check("no m3u while the playlist is empty",
app.write_playlist("p2") is None)
check("and no stray file left behind",
not (lib / "Nothing Landed Yet.m3u").exists())
make_playlist("p3", "../../escape", [
(0, "Tory Lanez", "The Color Violet", "imported", str(a))])
out = app.write_playlist("p3")
check("a traversing playlist name cannot escape the library",
out is None or out.resolve().is_relative_to(lib.resolve()), out)
# A track approved and then rejected has to leave the file.
with app.db() as c:
c.execute("UPDATE jobs SET state='failed' WHERE id=?", ("p1-0",))
app.write_playlist("p1")
check("rejecting a track drops it from the m3u",
"The Color Violet" not in dest.read_text(), dest.read_text())
print(f"\n{ok} passed, {fail} failed")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())