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>
This commit is contained in:
1 parent
e9fba3bb1d
commit
eeb0e096a7
7 files changed
+813
-17
No files matched your search
@@ -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/<Artist>/<Album> (<Year>)/<NN> - <Title>.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` | |
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 [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 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) {
|
||||
if (initial === undefined || initial === seeded.current) return;
|
||||
seeded.current = initial;
|
||||
typed.current = false;
|
||||
setQ(initial);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [initial]);
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// 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);
|
||||
|
||||
// Debounced so a fast typist makes one upstream call, not eight. The
|
||||
// AbortController matters more than the delay: without it a slow response
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user