Compare commits
1
Commits
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