• Import a shared playlist link and build it in Navidrome
    Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches
    every track through the same Deezer path a pasted line takes, and writes an
    .m3u the library scanner picks up.
    
    Written per import rather than at the end, so the playlist appears in Navidrome
    with the first track that lands and grows as the rest arrive -- twenty minutes
    of nothing, then everything, is the worse shape. A track rejected in review
    drops out on the next rewrite, and one that never resolves is reported instead
    of silently vanishing.
    
    Resolve is split from queue on purpose: a shared link is opaque, you cannot
    tell twelve tracks from four hundred by looking at it, and four hundred is a
    quota decision.
    
    Two of the three sources are scrapes and the code says so plainly. Deezer has a
    real API. Spotify and Apple Music have no free way to read a public playlist --
    Apple's MusicKit needs a paid developer account -- so those read the public page
    and parse the JSON it already embeds for its own player. No key, no OAuth, no
    account, and no guarantee: they break whenever either company reshuffles their
    frontend. So the resolvers fail loudly and name themselves, because an empty
    playlist and a changed page must not produce the same message.
    
    The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both
    containers mount the same directory at different points and a playlist written
    with muse's view resolves to nothing on Navidrome's side.
    
    Also reorders the hooks in search-bar so the URL-seeding effect is declared
    after the state it sets, rather than closing over a binding declared below it.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 changed files with 1018 additions and 19 deletions

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
# Build prompt — a self-hostable music request stack
Paste this into Claude Code in a **fresh empty repo**. It assumes no prior
context. Replace `<NAME>` with the project name before you start.
---
Build `<NAME>`: a one-command, self-hostable stack that turns "I want this
song" into a correctly-tagged file in a personal music library, reachable from
any Subsonic client.
You are creating a public open-source project that strangers will run on their
own hardware. Optimise for *someone else's first hour*, not for my convenience.
## What it is
Four containers, orchestrated:
| service | role | image |
|---|---|---|
| `gluetun` | VPN killswitch that slskd lives behind | `qmcgaw/gluetun` (pin the tag) |
| `slskd` | the Soulseek client — the only thing that speaks the protocol | `slskd/slskd` |
| `muse` | web front door: search, request, review, admin | `ghcr.io/geneticglitch1/muse` |
| `lidarr` | optional: album-level wishlist, retag, import | `lscr.io/linuxserver/lidarr` |
`muse` is an existing project (github.com/geneticglitch1/muse) — pull its image,
do not rebuild or vendor it. Read its README for its environment contract.
Optionally include `soularr` (`mrusse08/soularr`) to bridge Lidarr's wanted
list to slskd, and `navidrome` (`deluan/navidrome`) so the stack is complete
end to end for someone with no existing library server.
## Non-negotiable constraints
These are not preferences. Each one is a bug I have already paid for.
**1. Sharing is ON by default, and this is a compliance requirement.**
Soulseek's rules tolerate alternative clients that "implement the full range of
features … including chat, search, wishlist, download, **upload**, and respect /
recognition of privileges". A download-only deployment is the thing that rule
exists to prevent. So:
- `slskd` ships with the music library mounted read-only and shared.
- The README states plainly, near the top, that the stack shares by default and
that this is both the network's rule and the reason the network works.
- Disabling sharing must require deliberately editing a value that is commented
as antisocial and rule-breaking. Never make it a convenient flag.
- Do not add any feature that hides, fakes, or minimises upload participation.
**2. The VPN must fail closed.**
If the tunnel drops, slskd must lose network access, not fall back to the naked
connection. `network_mode: "service:gluetun"` plus a healthcheck, plus
`depends_on: {condition: service_healthy, restart: true}`. Verify this is real
by documenting a test that stops gluetun and shows slskd cannot reach the
internet.
**3. AirVPN (and similar) use STATIC reserved ports — gluetun cannot fetch them.**
gluetun's dynamic port forwarding does not work for AirVPN. Do not set
`VPN_PORT_FORWARDING` or slskd's `SLSKD_VPN_PORT_FORWARDING` for those
providers — slskd will wait forever for a port that never arrives. Instead the
user reserves a port in the provider's control panel and sets it in `.env`, and
the compose passes it to **both** `FIREWALL_VPN_INPUT_PORTS` (so inbound peers
are admitted on `tun0`) and `SLSKD_SLSK_LISTEN_PORT`.
Do **not** publish the Soulseek listen port with a docker `ports:` entry. Docker
DNAT does nothing for traffic arriving over the tunnel. Say this in a comment,
because everyone tries it.
This distinction — static-reservation providers vs. dynamic-forwarding
providers — needs its own docs section with a table, because getting it wrong
presents as "downloads are just slow and flaky", never as an error.
**4. One `/data` mount, never two.**
`link()` between two different *mounts* returns `EXDEV` even when both are on
the same filesystem. Split downloads and library into separate mounts and every
import silently degrades to a full copy, storing the library twice. Mount a
single `/data` and use subdirectories. Ship a preflight script that creates a
hardlink across the two subdirectories and fails loudly if it can't.
**5. Bind-mount targets must pre-exist, owned by the runtime UID.**
Docker creates missing bind targets as `root:root`, which breaks every
container running as a non-root user. The setup script creates them with the
right ownership before first start.
## Repository shape
```
docker-compose.yml profiles: vpn (default), no-vpn, lidarr, navidrome
.env.example every variable, commented, no real values
setup.sh interactive first-run: dirs, perms, secrets, preflight
docs/
README.md landing: what it is, 5-minute quickstart
vpn.md providers, static vs dynamic ports, verification
exposing.md reverse proxy, TLS, auth — the safe path
sharing.md why sharing is on, what the rules say, etiquette
troubleshooting.md symptom -> cause table
architecture.md what each container does and why it's separate
.github/workflows/ lint compose, shellcheck, link-check the docs
LICENSE AGPL-3.0 or MIT — ask me which before writing it
```
## The quickstart must actually be five minutes
```bash
git clone … && cd <NAME>
./setup.sh # asks for VPN creds, reserved port, Soulseek login
docker compose up -d
```
`setup.sh` is the difference between a project people use and a project people
star. It should:
- detect whether `docker compose` exists and is v2
- ask which VPN provider, and branch the port question on static vs dynamic
- generate random values for every secret that can be random (`slskd` API key,
admin password) rather than asking
- create directories with correct ownership
- run the hardlink preflight and **refuse to continue** if it fails, explaining
why in terms of the consequence ("your library will be stored twice")
- write `.env` and print exactly what to do next
- be idempotent — safe to re-run
## Exposing it safely
This is the section that decides whether the project harms its users, so it
gets real care. Cover, in this order of preference:
1. **Don't.** Tailscale / WireGuard / LAN-only. Recommended default, stated as
such. Most people asking do not actually need public access.
2. **Reverse proxy with OAuth in front.** Caddy and nginx-proxy-manager configs.
muse has GitHub/Google OAuth built in and fails closed without it — show how
to configure that, and note the callback URL rule.
3. **Never expose slskd or Lidarr directly.** Their auth is not built for the
open internet. Only muse, and only behind OAuth.
Include a short "if you expose this, understand" list: the VPN protects your
Soulseek traffic, not your web UI; your reverse proxy sees your real IP; the
admin panel can delete files.
## Compliance and legality — one honest section, no hedging
A `docs/legal.md`, linked from the README, stating plainly:
- Soulseek's rules tolerate full-featured alternative clients; slskd is one, and
this stack does not modify its protocol behaviour.
- Uploading is part of that; this stack shares by default and you should not
turn it off.
- Whether any given file is legal to download is on the operator, exactly as
with any download client. Quote Soulseek's own line: users should only share
and download files they are legally allowed to.
- This project is not affiliated with or endorsed by Soulseek, and does not
bundle or redistribute the official Soulseek client.
Say it once, clearly, and do not repeat it apologetically elsewhere.
## Engineering standards
- **Multi-arch.** amd64 and arm64. A large share of self-hosters are on a Pi or
an Apple-silicon Mac; an amd64-only compose is a wall of exec-format errors.
- **Pin every image tag.** `:latest` on a VPN container means an unattended pull
can drop the tunnel. Pin, and document how to update deliberately.
- **Healthchecks on everything** that something else depends on.
- **Resource limits** and log rotation (`max-size`, `max-file`) — an unbounded
json-file log has filled a homelab disk before.
- **No secrets in the repo, ever.** `.env` is gitignored; `.env.example` holds
placeholders only. Add a CI check that greps for anything key-shaped.
- **Comment the non-obvious, not the obvious.** Every constraint above earns a
comment explaining the *consequence* of getting it wrong, not a restatement of
the line of YAML.
## Verification — do this, don't assume it
Before telling me it works:
1. `docker compose config` renders with `.env.example` values filled in.
2. Both VPN profiles come up; `docker exec gluetun wget -qO- ifconfig.me` shows
a non-residential IP, and the same command in a no-VPN profile shows yours.
3. Kill gluetun; confirm slskd has no internet. This is the killswitch test.
4. The hardlink preflight passes, and a file imported into the library shows
link count 2.
5. slskd's UI reports connected, logged in, and a non-zero shared file count.
6. muse loads, search returns results, a request reaches slskd.
7. `shellcheck setup.sh` is clean; `setup.sh` is idempotent.
Report what you actually ran and what it printed. If something can't be tested
in your environment, say so explicitly rather than implying it passed.
## Work order
Ask me about the licence and the name first if I haven't said. Then:
1. `docker-compose.yml` + `.env.example` + `setup.sh` — the working core.
2. Verify it, per the list above.
3. Docs, written against the thing that now exists rather than the plan.
4. CI.
Show me the compose file and get agreement before writing the docs. Don't
generate all of it and ask me to review a hundred files at once.
+33
View File
@@ -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. a request that loses its peer falls through to the next candidate on its own.
See "When a peer says no" below. 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 ### Everything goes through a bounded queue
`MUSE_WORKERS` (default 3) caps concurrent Soulseek searches. This matters: `MUSE_WORKERS` (default 3) caps concurrent Soulseek searches. This matters:
@@ -599,6 +630,8 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking | | `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong | | `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_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_WORKERS` | `3` | concurrent Soulseek searches |
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above | | `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
| `MUSE_SMTP_PORT` | `587` | | | `MUSE_SMTP_PORT` | `587` | |
+347 -5
View File
@@ -596,9 +596,29 @@ def init_db():
("candidates", "TEXT"), ("candidates", "TEXT"),
("tried", "TEXT"), ("tried", "TEXT"),
("attempts", "INTEGER NOT NULL DEFAULT 0"), ("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: if col not in cols:
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}") 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()} ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"), for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"),
("invited_by", "TEXT"), ("invited_by", "TEXT"),
@@ -1534,6 +1554,10 @@ async def import_worker():
dest = tag_and_file(job, src) dest = tag_and_file(job, src)
set_state(job["id"], "imported", str(dest), set_state(job["id"], "imported", str(dest),
imported_path=str(dest), size=actual) 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) await notify_imported(job, dest)
elif "Completed" in st and "Succeeded" not in st: 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, async def enqueue_job(artist: str, title: str, album=None, year=None,
track=None, deezer_id=None, requested_by=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.""" """Create a job and hand it to the search queue."""
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
# The single choke point for all three request paths, so normalise here. # 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: with db() as c:
c.execute( c.execute(
"INSERT INTO jobs (id,state,detail,artist,title,album,year,track," "INSERT INTO jobs (id,state,detail,artist,title,album,year,track,"
"recording_mbid,release_mbid,requested_by,cover,genre,duration) " "recording_mbid,release_mbid,requested_by,cover,genre,duration,"
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "playlist_id,playlist_seq) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, "queued", "", artist, title, album, year, track, (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) await work_q.put(job_id)
return job_id return job_id
@@ -2398,6 +2425,218 @@ MAX_IMPORT_LINES = 500
MAX_IMPORT_TRACKS = 1000 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]: async def expand_url(kind: str, ident: str) -> list[str]:
"""Turn a Deezer album/playlist URL into 'Artist - Title' lines.""" """Turn a Deezer album/playlist URL into 'Artist - Title' lines."""
out = [] out = []
@@ -2475,6 +2714,101 @@ async def import_list(body: ImportBody, request: Request):
return {"queued": queued, "skipped": skipped, "pending": work_q.qsize()} 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): class ReviewBody(BaseModel):
action: str # approve | reject action: str # approve | reject
@@ -2532,6 +2866,9 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request):
except OSError as e: except OSError as e:
log.warning("review: could not delete %s: %s", src, e) log.warning("review: could not delete %s: %s", src, e)
set_state(job_id, "failed", "rejected in review", reviewed_by=me) 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"} return {"ok": True, "state": "failed"}
if not src: if not src:
@@ -2539,6 +2876,11 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request):
dest = tag_and_file(job, src) dest = tag_and_file(job, src)
set_state(job_id, "imported", str(dest), imported_path=str(dest), set_state(job_id, "imported", str(dest), imported_path=str(dest),
reviewed_by=me) 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]) log.info("review: %s approved %s", me, job_id[:8])
return {"ok": True, "state": "imported", "path": str(dest)} return {"ok": True, "state": "imported", "path": str(dest)}
+176
View File
@@ -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())
+7 -1
View File
@@ -14,6 +14,7 @@ import { TrackRow } from "@/components/track-row";
import { AlbumView } from "@/components/album-view"; import { AlbumView } from "@/components/album-view";
import { ArtistView } from "@/components/artist-view"; import { ArtistView } from "@/components/artist-view";
import { BulkImport } from "@/components/bulk-import"; import { BulkImport } from "@/components/bulk-import";
import { PlaylistImport } from "@/components/playlist-import";
import { Requests, type JobsSnapshot } from "@/components/requests"; import { Requests, type JobsSnapshot } from "@/components/requests";
import { Review } from "@/components/review"; import { Review } from "@/components/review";
import { DiscoverPanel } from "@/components/discover"; import { DiscoverPanel } from "@/components/discover";
@@ -203,7 +204,12 @@ function Muse() {
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />} {nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />} {nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
{nav.tab === "review" && <Review onDecided={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 === "settings" && me && <Settings me={me} refresh={refresh} />}
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />} {nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
</motion.div> </motion.div>
+204
View File
@@ -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>
);
}
+15 -13
View File
@@ -23,24 +23,26 @@ export function SearchBar({
onArtist: (id: number) => void; onArtist: (id: number) => void;
}) { }) {
const [q, setQ] = React.useState(initial ?? ""); 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 [items, setItems] = React.useState<Item[]>([]);
const [open, setOpen] = React.useState(false); 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 // 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. // results that link was for. Only typing counts as asking for suggestions.
const typed = React.useRef(false); const typed = React.useRef(false);
const [sel, setSel] = React.useState(-1); const seeded = React.useRef(initial);
const inputRef = React.useRef<HTMLInputElement>(null);
// 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 // Debounced so a fast typist makes one upstream call, not eight. The
// AbortController matters more than the delay: without it a slow response // AbortController matters more than the delay: without it a slow response
+33
View File
@@ -266,7 +266,40 @@ export type Health = {
downloads_readable?: boolean; 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 = { 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"), me: () => get<Me>("/api/me"),
health: () => get<Health>("/api/health"), health: () => get<Health>("/api/health"),
search: (q: string) => search: (q: string) =>