diff --git a/README.md b/README.md index f127ba2..c4fd6fa 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,37 @@ Failed jobs get a **Retry** button, but far fewer jobs reach that state now: a request that loses its peer falls through to the next candidate on its own. See "When a peer says no" below. +## Shared playlist links + +Paste a Spotify, Apple Music or Deezer playlist link and muse resolves it, +matches every track the same way a pasted line is matched, and writes an `.m3u` +into the library that Navidrome picks up on its next scan — within seconds, in +practice. The playlist is written **per import**, not at the end, so it appears +with the first track that lands and grows as the rest arrive. Tracks that never +resolve are reported rather than silently dropped, and a track rejected in +review disappears from the playlist on the next rewrite. + +Resolving is deliberately separate from queueing. A shared link is opaque — you +cannot tell twelve tracks from four hundred by looking at it — so the track list +and count are shown before anything is committed against a quota. + +**Two of the three sources are scrapes, and this is worth understanding before +you rely on it.** Deezer has a real public API. Spotify and Apple Music have no +free way to read a public playlist — Apple's MusicKit requires a paid developer +account — so muse reads the public web page and parses the JSON blob the page +already embeds for its own player. No key, no OAuth, no account. It also means +those two will break the day either company reshuffles their frontend. The +resolvers therefore fail loudly and specifically ("Spotify's embed page no +longer carries `__NEXT_DATA__` — the resolver in app.py needs updating") rather +than quietly returning nothing, because an empty playlist and a changed page +must not look the same to whoever has to fix it. + +One detail that is easy to get wrong: the `.m3u` has to use **Navidrome's** +mount path, not muse's. Both containers see the same directory, muse at +`/data/media/music` and Navidrome at `/music`, and a playlist written with the +former resolves to nothing on the other side. `MUSE_PLAYLIST_PREFIX` is that +path. + ### Everything goes through a bounded queue `MUSE_WORKERS` (default 3) caps concurrent Soulseek searches. This matters: @@ -599,6 +630,8 @@ stat -c "%h" "/data/media/music// ()/ - .flac" | `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking | | `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong | | `MUSE_LENGTH_TOLERANCE` | `0.2` | duration drift before a file is called a different recording | +| `MUSE_MAX_PLAYLIST_TRACKS` | `500` | cap on one playlist link | +| `MUSE_PLAYLIST_PREFIX` | `/music` | the library path *Navidrome* sees, for `.m3u` lines | | `MUSE_WORKERS` | `3` | concurrent Soulseek searches | | `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above | | `MUSE_SMTP_PORT` | `587` | | diff --git a/app.py b/app.py index eb2723e..bf9208f 100644 --- a/app.py +++ b/app.py @@ -596,9 +596,29 @@ def init_db(): ("candidates", "TEXT"), ("tried", "TEXT"), ("attempts", "INTEGER NOT NULL DEFAULT 0"), - ("started_at", "TEXT")): + ("started_at", "TEXT"), + # Which shared playlist this track came from, and + # where it sits in it -- an m3u in shuffled order is + # not the playlist anyone sent you. + ("playlist_id", "TEXT"), + ("playlist_seq", "INTEGER")): if col not in cols: c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}") + c.execute(""" + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source TEXT NOT NULL, -- deezer|spotify|apple + source_url TEXT, + requested_by TEXT, + tracks_total INTEGER NOT NULL DEFAULT 0, + tracks_written INTEGER NOT NULL DEFAULT 0, + m3u_path TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS jobs_by_playlist " + "ON jobs (playlist_id)") ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()} for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"), ("invited_by", "TEXT"), @@ -1534,6 +1554,10 @@ async def import_worker(): dest = tag_and_file(job, src) set_state(job["id"], "imported", str(dest), imported_path=str(dest), size=actual) + # Rewritten per track, so the playlist appears in Navidrome + # with the first arrival rather than the last. + if playlist_of(job): + write_playlist(job["playlist_id"]) await notify_imported(job, dest) elif "Completed" in st and "Succeeded" not in st: @@ -2300,7 +2324,8 @@ async def artist_detail(artist_id: int): async def enqueue_job(artist: str, title: str, album=None, year=None, track=None, deezer_id=None, requested_by=None, - cover=None, genre=None, duration=None) -> str: + cover=None, genre=None, duration=None, + playlist_id=None, playlist_seq=None) -> str: """Create a job and hand it to the search queue.""" job_id = str(uuid.uuid4()) # The single choke point for all three request paths, so normalise here. @@ -2313,10 +2338,12 @@ async def enqueue_job(artist: str, title: str, album=None, year=None, with db() as c: c.execute( "INSERT INTO jobs (id,state,detail,artist,title,album,year,track," - "recording_mbid,release_mbid,requested_by,cover,genre,duration) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "recording_mbid,release_mbid,requested_by,cover,genre,duration," + "playlist_id,playlist_seq) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (job_id, "queued", "", artist, title, album, year, track, - str(deezer_id or ""), None, requested_by, cover, genre, duration)) + str(deezer_id or ""), None, requested_by, cover, genre, duration, + playlist_id, playlist_seq)) await work_q.put(job_id) return job_id @@ -2398,6 +2425,218 @@ MAX_IMPORT_LINES = 500 MAX_IMPORT_TRACKS = 1000 +# ─────────────────────────── playlists ─────────────────────────── +# +# Paste a playlist link from wherever it was shared and get the tracks. +# +# Deezer is a real API. Spotify and Apple Music are NOT: neither offers a free +# way to read a public playlist -- Apple's MusicKit needs a paid developer +# account -- so those two read the public web page and pull the JSON blob the +# page already embeds for its own client. That works, and it needs no key, no +# OAuth and no account. It is also a scrape, and it will break the day either +# company reshuffles their frontend. +# +# So the failure is made loud and specific rather than silent. A resolver that +# finds the page but not the tracks says the format changed, because "0 tracks" +# and "this playlist is empty" must not look the same to whoever has to fix it. + +SPOTIFY_URL = re.compile( + r"open\.spotify\.com/(?:intl-[a-z-]+/)?(playlist|album)/([A-Za-z0-9]+)") +APPLE_URL = re.compile( + r"music\.apple\.com/[a-z]{2}/(playlist|album)/[^/]+/(pl\.[A-Za-z0-9.-]+|\d+)") + +# Playlists are capped harder than a paste: one link can be thousands of tracks +# and the quota check cannot run until the whole thing has been resolved. +MAX_PLAYLIST_TRACKS = int(os.getenv("MUSE_MAX_PLAYLIST_TRACKS", "500")) + +BROWSERISH = { + # These are public pages; a default httpx agent gets a different response + # from a real one on both hosts. + "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0 Safari/537.36"), + "Accept-Language": "en-US,en;q=0.9", +} + + +class PlaylistUnavailable(Exception): + """The link could not be turned into a track list.""" + + +def _walk(node, want: tuple[str, ...], out: list): + """Collect every dict carrying all of `want`, at any depth.""" + if isinstance(node, dict): + if all(k in node for k in want): + out.append(node) + for v in node.values(): + _walk(v, want, out) + elif isinstance(node, list): + for v in node: + _walk(v, want, out) + + +async def _fetch(url: str) -> str: + async with httpx.AsyncClient(timeout=25, follow_redirects=True, + headers=BROWSERISH) as c: + r = await c.get(url) + if r.status_code == 404: + raise PlaylistUnavailable("that playlist does not exist, or is private") + r.raise_for_status() + return r.text + + +async def _deezer_playlist(kind: str, ident: str) -> tuple[str, list[tuple[str, str]]]: + async with httpx.AsyncClient(timeout=25) as c: + r = await c.get(f"{DEEZER}/{kind}/{ident}") + if r.status_code != 200: + raise PlaylistUnavailable(f"Deezer returned {r.status_code}") + d = r.json() + if d.get("error"): + raise PlaylistUnavailable(str(d["error"])[:120]) + tracks = [((t.get("artist") or {}).get("name") or "", t.get("title") or "") + for t in (d.get("tracks") or {}).get("data", [])] + return d.get("title") or f"Deezer {kind}", [t for t in tracks if t[1]] + + +async def _spotify_playlist(kind: str, ident: str) -> tuple[str, list[tuple[str, str]]]: + # The embed player is server-rendered with the whole track list inline, + # which is why this needs no credentials. + html = await _fetch(f"https://open.spotify.com/embed/{kind}/{ident}") + m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.S) + if not m: + raise PlaylistUnavailable( + "Spotify's embed page no longer carries __NEXT_DATA__ — the " + "resolver in app.py needs updating") + try: + entity = (json.loads(m.group(1))["props"]["pageProps"]["state"] + ["data"]["entity"]) + except (KeyError, ValueError) as e: + raise PlaylistUnavailable( + f"Spotify's embed payload changed shape ({errstr(e)}) — the " + "resolver in app.py needs updating") from e + items = entity.get("trackList") or [] + if not items: + raise PlaylistUnavailable("Spotify returned no tracks for that link") + # title is the song, subtitle is the artist. + return (entity.get("name") or f"Spotify {kind}", + [(t.get("subtitle") or "", t.get("title") or "") for t in items + if t.get("title")]) + + +async def _apple_playlist(kind: str, ident: str, url: str) -> tuple[str, list[tuple[str, str]]]: + html = await _fetch(url) + m = re.search(r'id="serialized-server-data"[^>]*>(.*?)</script>', html, re.S) + if not m: + raise PlaylistUnavailable( + "Apple Music's page no longer carries serialized-server-data — " + "the resolver in app.py needs updating") + try: + data = json.loads(m.group(1)) + except ValueError as e: + raise PlaylistUnavailable( + f"Apple Music's payload did not parse ({errstr(e)})") from e + rows: list = [] + _walk(data, ("title", "artistName"), rows) + if not rows: + raise PlaylistUnavailable( + "Apple Music's payload changed shape — the resolver in app.py " + "needs updating") + name = "" + heads: list = [] + _walk(data, ("headerTitle",), heads) + if heads: + name = heads[0].get("headerTitle") or "" + seen, tracks = set(), [] + for t in rows: + key = (t.get("artistName"), t.get("title")) + if key in seen: + continue + seen.add(key) + tracks.append((t.get("artistName") or "", t.get("title") or "")) + return name or f"Apple Music {kind}", tracks + + +async def resolve_playlist(url: str) -> tuple[str, str, list[tuple[str, str]]]: + """Turn a shared playlist link into (source, name, [(artist, title), ...]).""" + url = url.strip() + if m := DEEZER_URL.search(url): + name, tracks = await _deezer_playlist(*m.groups()) + source = "deezer" + elif m := SPOTIFY_URL.search(url): + name, tracks = await _spotify_playlist(*m.groups()) + source = "spotify" + elif m := APPLE_URL.search(url): + name, tracks = await _apple_playlist(*m.groups(), url) + source = "apple" + else: + raise PlaylistUnavailable( + "that link is not a Deezer, Spotify or Apple Music playlist") + if not tracks: + raise PlaylistUnavailable("that playlist appears to be empty") + if len(tracks) > MAX_PLAYLIST_TRACKS: + raise PlaylistUnavailable( + f"that playlist has {len(tracks)} tracks; the limit is " + f"{MAX_PLAYLIST_TRACKS}") + return source, name, tracks + + +# Navidrome mounts the library at a different path than muse does -- /music +# against /data/media/music -- so an m3u written with muse's own paths resolves +# to nothing on the other side of the wall. +PLAYLIST_PREFIX = os.getenv("MUSE_PLAYLIST_PREFIX", "/music") + + +def write_playlist(playlist_id: str) -> Path | None: + """Write the .m3u for a playlist, or remove it when nothing has landed yet. + + Rewritten from scratch every time one of its tracks imports, which is both + simpler than patching a file in place and the reason the playlist shows up + in Navidrome as soon as the first track arrives instead of after the last. + """ + with db() as c: + pl = c.execute("SELECT * FROM playlists WHERE id=?", + (playlist_id,)).fetchone() + if not pl: + return None + rows = c.execute( + "SELECT artist,title,imported_path FROM jobs WHERE playlist_id=? " + "AND state='imported' AND imported_path IS NOT NULL " + "ORDER BY playlist_seq", (playlist_id,)).fetchall() + + dest = LIBRARY_DIR / f"{safe(pl['name'])}.m3u" + root = LIBRARY_DIR.resolve() + if not Path(os.path.normpath(dest)).resolve().is_relative_to(root): + log.warning("playlist %s: refusing to write outside the library", playlist_id) + return None + + lines = ["#EXTM3U", f"#PLAYLIST:{pl['name']}"] + for r in rows: + try: + rel = Path(r["imported_path"]).resolve().relative_to(root) + except ValueError: + continue # imported somewhere else entirely; not ours to list + lines.append(f"#EXTINF:-1,{r['artist']} - {r['title']}") + lines.append(f"{PLAYLIST_PREFIX}/{rel.as_posix()}") + + if len(lines) == 2: + # Nothing imported yet. Leaving a two-line file behind would make + # Navidrome show an empty playlist, which reads as a failure. + if dest.exists(): + dest.unlink() + return None + dest.write_text("\n".join(lines) + "\n", encoding="utf-8") + with db() as c: + c.execute("UPDATE playlists SET m3u_path=?, tracks_written=? WHERE id=?", + (str(dest), len(rows), playlist_id)) + log.info("playlist %s -> %s (%d tracks)", playlist_id[:8], dest, len(rows)) + return dest + + +def playlist_of(job) -> str | None: + keys = job.keys() if hasattr(job, "keys") else () + return job["playlist_id"] if "playlist_id" in keys else None + + async def expand_url(kind: str, ident: str) -> list[str]: """Turn a Deezer album/playlist URL into 'Artist - Title' lines.""" out = [] @@ -2475,6 +2714,101 @@ async def import_list(body: ImportBody, request: Request): return {"queued": queued, "skipped": skipped, "pending": work_q.qsize()} +class PlaylistBody(BaseModel): + url: str + # Resolve and show what is in it without queueing anything. A shared link + # is opaque -- you cannot tell 12 tracks from 400 by looking at it -- and + # queueing 400 against a quota you cannot see first is a bad trade. + preview: bool = False + name: str | None = None + + +@app.post("/api/playlist") +async def playlist_import(body: PlaylistBody, request: Request): + """Turn a shared playlist link into queued requests and an m3u. + + Every track goes through the same Deezer resolution a pasted line does, so + the matching, ranking and review behaviour is identical; the only new thing + is that the results stay grouped and ordered. + """ + try: + source, name, tracks = await resolve_playlist(body.url) + except PlaylistUnavailable as e: + raise HTTPException(422, str(e)) + except Exception as e: + raise HTTPException(502, f"could not read that playlist: {errstr(e)}") + + name = (body.name or name).strip()[:MAX_COMPONENT] or "Playlist" + + if body.preview: + return {"source": source, "name": name, "count": len(tracks), + "tracks": [{"artist": a, "title": t} for a, t in tracks[:100]]} + + check_quota(request, adding=len(tracks)) + + playlist_id = str(uuid.uuid4()) + with db() as c: + c.execute("INSERT INTO playlists (id,name,source,source_url," + "requested_by,tracks_total) VALUES (?,?,?,?,?,?)", + (playlist_id, name, source, body.url[:500], + session_user(request), len(tracks))) + + queued, skipped = [], [] + for seq, (artist, title) in enumerate(tracks): + line = f"{artist} {title}".strip() + try: + hits = await deezer_search(SPLIT.sub(" ", line), limit=10) + except Exception as e: + skipped.append({"line": line, "why": errstr(e)}) + continue + if not hits: + skipped.append({"line": line, "why": "no match on Deezer"}) + continue + h = hits[0] + e = await enrich(h["deezer_id"]) + jid = await enqueue_job( + e.get("artist") or h["artist"], h["title"], + e.get("album") or h["album"], e.get("year"), e.get("track"), + h["deezer_id"], requested_by=session_user(request), + cover=h.get("cover"), duration=h.get("length"), + playlist_id=playlist_id, playlist_seq=seq) + queued.append({"id": jid, "line": line, "cover": h.get("cover"), + "matched": f"{h['artist']} — {h['title']}"}) + + log.info("playlist %s (%s) %r: %d queued, %d skipped", + playlist_id[:8], source, name, len(queued), len(skipped)) + return {"playlist_id": playlist_id, "name": name, "source": source, + "queued": queued, "skipped": skipped, "pending": work_q.qsize()} + + +@app.get("/api/playlists") +async def playlists(request: Request): + """Playlists and how far along each one is. + + Shared on purpose: an m3u on disk is visible to every Navidrome user + regardless, so pretending otherwise here would only be a fiction. + """ + with db() as c: + rows = c.execute( + "SELECT * FROM playlists ORDER BY created_at DESC LIMIT 100" + ).fetchall() + counts = {} + for r in c.execute( + "SELECT playlist_id, state, COUNT(*) n FROM jobs " + "WHERE playlist_id IS NOT NULL GROUP BY playlist_id, state"): + counts.setdefault(r["playlist_id"], {})[r["state"]] = r["n"] + out = [] + for r in rows: + c_ = counts.get(r["id"], {}) + done = c_.get("imported", 0) + out.append({**dict(r), "counts": c_, "done": done, + "pending": sum(c_.get(s, 0) for s in + ("queued", "searching", "downloading")), + "failed": c_.get("failed", 0), + "review": c_.get("review", 0)}) + return {"playlists": out} + + class ReviewBody(BaseModel): action: str # approve | reject @@ -2532,6 +2866,9 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request): except OSError as e: log.warning("review: could not delete %s: %s", src, e) set_state(job_id, "failed", "rejected in review", reviewed_by=me) + # It may already be listed from an earlier pass; the rewrite drops it. + if playlist_of(job): + write_playlist(job["playlist_id"]) return {"ok": True, "state": "failed"} if not src: @@ -2539,6 +2876,11 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request): dest = tag_and_file(job, src) set_state(job_id, "imported", str(dest), imported_path=str(dest), reviewed_by=me) + # After set_state, not before: write_playlist reads imported_path back out + # of the row, so running it first would write a playlist missing the very + # track that was just approved. + if playlist_of(job): + write_playlist(job["playlist_id"]) log.info("review: %s approved %s", me, job_id[:8]) return {"ok": True, "state": "imported", "path": str(dest)} diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 0000000..8c27a82 --- /dev/null +++ b/tests/test_playlist.py @@ -0,0 +1,176 @@ +"""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 = ( + '<html><script id="__NEXT_DATA__" type="application/json">' + + json.dumps({"props": {"pageProps": {"state": {"data": {"entity": { + "name": "Late Night", + "trackList": [ + {"title": "The Color Violet", "subtitle": "Tory Lanez"}, + {"title": "No Pole", "subtitle": "Don Toliver"}, + ]}}}}}}) + + "</script></html>") + +APPLE_PAGE = ( + '<html><script id="serialized-server-data" type="application/json">' + + json.dumps([{"data": {"sections": [ + {"headerTitle": "Heavy Rotation"}, + {"items": [ + {"title": "YUKON", "artistName": "Justin Bieber"}, + {"title": "LET THE WORLD BURN", "artistName": "Chris Grey"}, + {"title": "YUKON", "artistName": "Justin Bieber"}, # dupe + ]}]}}]) + + "</script></html>") + + +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("<html>nothing useful here</html>") + 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()) diff --git a/web/app/page.tsx b/web/app/page.tsx index 07944d0..12deeb4 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -14,6 +14,7 @@ import { TrackRow } from "@/components/track-row"; import { AlbumView } from "@/components/album-view"; import { ArtistView } from "@/components/artist-view"; import { BulkImport } from "@/components/bulk-import"; +import { PlaylistImport } from "@/components/playlist-import"; import { Requests, type JobsSnapshot } from "@/components/requests"; import { Review } from "@/components/review"; import { DiscoverPanel } from "@/components/discover"; @@ -203,7 +204,12 @@ function Muse() { {nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />} {nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />} {nav.tab === "review" && <Review onDecided={refresh} />} - {nav.tab === "bulk" && <BulkImport onQueued={refresh} />} + {nav.tab === "bulk" && ( + <div className="flex flex-col gap-4"> + <PlaylistImport onQueued={refresh} /> + <BulkImport onQueued={refresh} /> + </div> + )} {nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />} {nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />} </motion.div> diff --git a/web/components/playlist-import.tsx b/web/components/playlist-import.tsx new file mode 100644 index 0000000..f8c58bf --- /dev/null +++ b/web/components/playlist-import.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Link2, ListMusic, Loader2, CheckCircle2 } from "lucide-react"; +import { api, type PlaylistPreview, type PlaylistRow } from "@/lib/api"; +import { useToast } from "@/components/toast"; +import { Button } from "@/components/ui/button"; +import { Card, CardTitle, CardNote } from "@/components/ui/card"; +import { Input } from "@/components/ui/field"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { useMotionOK, item } from "@/lib/motion"; + +const SOURCE: Record<string, string> = { + spotify: "Spotify", + apple: "Apple Music", + deezer: "Deezer", +}; + +/** + * Paste a shared link, get the playlist. + * + * Resolve is deliberately split from queue. A shared link is opaque — you + * cannot tell twelve tracks from four hundred by looking at it — and four + * hundred is a quota decision, so the tracks are shown before anything is + * committed. + */ +export function PlaylistImport({ onQueued }: { onQueued?: () => void }) { + const toast = useToast(); + const ok = useMotionOK(); + const [url, setUrl] = React.useState(""); + const [name, setName] = React.useState(""); + const [preview, setPreview] = React.useState<PlaylistPreview | null>(null); + const [busy, setBusy] = React.useState<"idle" | "resolving" | "queueing">("idle"); + const [rows, setRows] = React.useState<PlaylistRow[] | null>(null); + + // Not async, and resolving through .then: setState then happens in a promise + // callback rather than synchronously inside the effect, which is both what + // React wants and what the rest of the polling components here already do. + const refresh = React.useCallback(() => { + api + .playlists() + .then((d) => setRows(d.playlists)) + .catch(() => { + /* a 401 here just means the poll outlived the session */ + }); + }, []); + + React.useEffect(() => { + refresh(); + const t = setInterval(refresh, 5000); + return () => clearInterval(t); + }, [refresh]); + + const resolve = async () => { + if (!url.trim()) return toast("Paste a playlist link first", true); + setBusy("resolving"); + setPreview(null); + try { + const d = await api.playlistPreview(url.trim()); + setPreview(d); + setName(d.name); + } catch (e) { + toast((e as Error).message, true); + } + setBusy("idle"); + }; + + const queue = async () => { + setBusy("queueing"); + try { + const d = await api.playlistImport(url.trim(), name.trim() || undefined); + toast( + `Queued ${d.queued.length} of ${d.queued.length + d.skipped.length} from ${d.name}`, + ); + setUrl(""); + setPreview(null); + onQueued?.(); + refresh(); + } catch (e) { + toast((e as Error).message, true); + } + setBusy("idle"); + }; + + return ( + <div className="flex flex-col gap-4"> + <Card pad="lg"> + <CardTitle>Import a shared playlist</CardTitle> + <CardNote className="mt-1 mb-3.5 max-w-[64ch]"> + Paste a link from Spotify, Apple Music or Deezer. Every track is matched on + Deezer and queued like any other request, and the playlist itself appears in + Navidrome — growing as tracks arrive rather than all at once. + </CardNote> + + <div className="flex flex-col gap-2 sm:flex-row"> + <Input + value={url} + onChange={(e) => setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && busy === "idle" && resolve()} + placeholder="https://open.spotify.com/playlist/…" + aria-label="Playlist link" + inputMode="url" + /> + <Button onClick={resolve} disabled={busy !== "idle"} className="shrink-0"> + {busy === "resolving" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />} + Resolve + </Button> + </div> + + <AnimatePresence> + {preview && ( + <motion.div + initial={ok ? { opacity: 0, height: 0 } : false} + animate={{ opacity: 1, height: "auto" }} + exit={ok ? { opacity: 0, height: 0 } : { opacity: 0 }} + className="overflow-hidden" + > + <div className="mt-4 rounded-card border border-edge bg-[var(--surface-2)] p-3.5"> + <div className="mb-2.5 flex flex-wrap items-center gap-2"> + <Badge tone="accent">{SOURCE[preview.source] ?? preview.source}</Badge> + <span className="text-callout text-dim nums"> + {preview.count} track{preview.count === 1 ? "" : "s"} + </span> + </div> + <label className="mb-1 block text-caption font-medium text-dim"> + Name in Navidrome + </label> + <Input value={name} onChange={(e) => setName(e.target.value)} className="mb-3" /> + + <ol className="mb-3 max-h-52 overflow-y-auto text-callout"> + {preview.tracks.map((t, i) => ( + <li key={i} className="flex gap-2 border-b border-edge/60 py-1 last:border-0"> + <span className="w-6 shrink-0 text-right text-mute nums">{i + 1}</span> + <span className="clamp1"> + {t.title} <span className="text-dim">· {t.artist}</span> + </span> + </li> + ))} + {preview.count > preview.tracks.length && ( + <li className="py-1 text-caption text-mute"> + …and {preview.count - preview.tracks.length} more + </li> + )} + </ol> + + <Button onClick={queue} disabled={busy !== "idle"}> + {busy === "queueing" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} + Queue all {preview.count} + </Button> + </div> + </motion.div> + )} + </AnimatePresence> + </Card> + + {rows && rows.length > 0 && ( + <Card pad="lg"> + <CardTitle className="mb-3">Playlists</CardTitle> + <div className="flex flex-col gap-2.5"> + {rows.map((p, i) => { + const total = p.tracks_total || 1; + const settled = p.done + p.failed; + return ( + <motion.div + key={p.id} + {...item(i, ok)} + className="rounded-card border border-edge bg-[var(--surface-2)] p-3" + > + <div className="mb-1.5 flex items-center gap-2"> + <ListMusic className="h-3.5 w-3.5 shrink-0 text-mute" /> + <span className="min-w-0 flex-1 text-body font-semibold clamp1">{p.name}</span> + <Badge tone="neutral">{SOURCE[p.source] ?? p.source}</Badge> + </div> + <Progress + value={(p.done / total) * 100} + tone={p.failed && settled >= total ? "warn" : "accent"} + size="sm" + label={`${p.name} progress`} + /> + <div className="mt-1.5 flex flex-wrap gap-x-3 text-caption text-dim nums"> + <span className="text-ok">{p.done} in library</span> + {p.pending > 0 && <span>{p.pending} in flight</span>} + {p.review > 0 && <span className="text-warn">{p.review} to review</span>} + {p.failed > 0 && <span className="text-err">{p.failed} failed</span>} + <span className="text-mute">of {p.tracks_total}</span> + </div> + {p.m3u_path && ( + <div className="mt-1 flex items-center gap-1 text-caption text-mute"> + <CheckCircle2 className="h-3 w-3 text-ok" /> + in Navidrome with {p.tracks_written} track + {p.tracks_written === 1 ? "" : "s"} + </div> + )} + </motion.div> + ); + })} + </div> + </Card> + )} + </div> + ); +} diff --git a/web/components/search-bar.tsx b/web/components/search-bar.tsx index 8ed79b8..1703a1f 100644 --- a/web/components/search-bar.tsx +++ b/web/components/search-bar.tsx @@ -23,24 +23,26 @@ export function SearchBar({ onArtist: (id: number) => void; }) { const [q, setQ] = React.useState(initial ?? ""); - // Follows the URL on Back/Forward, but never fights what is being typed: - // only a genuinely different incoming query replaces the field. - const seeded = React.useRef(initial); - React.useEffect(() => { - if (initial !== undefined && initial !== seeded.current) { - seeded.current = initial; - typed.current = false; - setQ(initial); - setOpen(false); - } - }, [initial]); const [items, setItems] = React.useState<Item[]>([]); const [open, setOpen] = React.useState(false); + const [sel, setSel] = React.useState(-1); + const inputRef = React.useRef<HTMLInputElement>(null); // A value seeded from the URL must not pop the suggestion list open over the // results that link was for. Only typing counts as asking for suggestions. const typed = React.useRef(false); - const [sel, setSel] = React.useState(-1); - const inputRef = React.useRef<HTMLInputElement>(null); + const seeded = React.useRef(initial); + + // Follows the URL on Back/Forward, but never fights what is being typed: + // only a genuinely different incoming query replaces the field. Declared + // after the state it sets, or the effect closes over a binding that is not + // initialised yet. + React.useEffect(() => { + if (initial === undefined || initial === seeded.current) return; + seeded.current = initial; + typed.current = false; + setQ(initial); + setOpen(false); + }, [initial]); // Debounced so a fast typist makes one upstream call, not eight. The // AbortController matters more than the delay: without it a slow response diff --git a/web/lib/api.ts b/web/lib/api.ts index ace242e..bd88f83 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -266,7 +266,40 @@ export type Health = { downloads_readable?: boolean; }; +export type PlaylistPreview = { + source: "deezer" | "spotify" | "apple"; + name: string; + count: number; + tracks: { artist: string; title: string }[]; +}; + +export type PlaylistRow = { + id: string; + name: string; + source: string; + source_url: string | null; + tracks_total: number; + tracks_written: number; + m3u_path: string | null; + created_at: string; + done: number; + pending: number; + failed: number; + review: number; +}; + export const api = { + playlistPreview: (url: string) => + post<PlaylistPreview>("/api/playlist", { url, preview: true }), + playlistImport: (url: string, name?: string) => + post<{ + playlist_id: string; + name: string; + source: string; + queued: { id: string; line: string; matched: string; cover: string | null }[]; + skipped: { line: string; why: string }[]; + }>("/api/playlist", { url, name }), + playlists: () => get<{ playlists: PlaylistRow[] }>("/api/playlists"), me: () => get<Me>("/api/me"), health: () => get<Health>("/api/health"), search: (q: string) =>