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.
|
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` | |
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in new issue
Block a user