Rebuild the UI on Next.js, and give search real metadata
The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.
Search now carries what a music UI needs:
- year and genre, grafted on from /album/{id} concurrently, deduplicated by
album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
with no image proxy.
/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.
Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.
CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.
Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc46fd80b3
commit
3b4b68daaf
34 files changed
+10655
-281
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
# node_modules is ~400MB and is reinstalled from the lockfile inside the
|
||||||
|
# build stage anyway — sending it as build context would dwarf the image.
|
||||||
|
web/node_modules
|
||||||
|
web/.next
|
||||||
|
web/out
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
config/
|
||||||
|
.venv/
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.DS_Store
|
||||||
|
*.md
|
||||||
@@ -28,11 +28,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Sanity-check the app before publishing
|
- name: Sanity-check the API before publishing
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install --quiet -r requirements.txt
|
python3 -m pip install --quiet -r requirements.txt
|
||||||
python3 -c "import app; print('app imports OK')"
|
python3 -c "import app; print('app imports OK')"
|
||||||
|
|
||||||
|
# Typechecks and compiles the UI. The Docker build runs this too, but
|
||||||
|
# doing it up front fails in ~40s instead of after the whole image is
|
||||||
|
# assembled, and surfaces TS errors as their own step in the log.
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Build the UI
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
|
|||||||
+22
-2
@@ -1,4 +1,24 @@
|
|||||||
# Slim, no build toolchain needed — every dependency ships wheels for
|
# ── stage 1: build the UI ───────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Next.js is a BUILD-time dependency only. `output: "export"` emits plain
|
||||||
|
# HTML/JS/CSS, so Node never runs in the final image and the runtime stays a
|
||||||
|
# single Python process — same container, same healthcheck, same everything
|
||||||
|
# the deploy already expects.
|
||||||
|
FROM node:22-slim AS web
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Copied separately so editing a component doesn't re-run npm ci.
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ── stage 2: runtime ────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Slim, no build toolchain needed — every Python dependency ships wheels for
|
||||||
# linux/amd64 on py3.12.
|
# linux/amd64 on py3.12.
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
@@ -8,7 +28,7 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY static ./static
|
COPY --from=web /web/out ./web/out
|
||||||
|
|
||||||
# Runs as 1000:1000 via the compose `user:` key, matching every other
|
# Runs as 1000:1000 via the compose `user:` key, matching every other
|
||||||
# container that touches /data. Nothing here needs root.
|
# container that touches /data. Nothing here needs root.
|
||||||
|
|||||||
@@ -50,6 +50,62 @@ Deezer's API needs no key and no OAuth. Its search payload omits track number
|
|||||||
and release date, so those come from `/track/{id}` at request time — one extra
|
and release date, so those come from `/track/{id}` at request time — one extra
|
||||||
call per request, not per search result.
|
call per request, not per search result.
|
||||||
|
|
||||||
|
## The interface
|
||||||
|
|
||||||
|
Next.js 16 + React 19 + Tailwind 4 + Framer Motion, built as a **static
|
||||||
|
export** and served by the FastAPI process.
|
||||||
|
|
||||||
|
That last part is the design decision worth stating: `next start` alongside
|
||||||
|
uvicorn would mean a Node runtime in the image, a second process to supervise,
|
||||||
|
and a second thing that can be down while the other is up. muse has no use for
|
||||||
|
SSR — every view sits behind auth and renders from live JSON — so `next build`
|
||||||
|
emits plain files, Python serves them, and the deployment stays one container,
|
||||||
|
one process, one healthcheck.
|
||||||
|
|
||||||
|
What the UI does with the extra metadata:
|
||||||
|
|
||||||
|
- **Search returns songs with artwork, album, year, genre, duration** and an
|
||||||
|
explicit flag. Year and genre aren't in Deezer's track search, so they're
|
||||||
|
grafted on from `/album/{id}` — concurrently, deduplicated by album, and
|
||||||
|
cached forever, because a release date is immutable.
|
||||||
|
- **Autocomplete** across songs, albums and artists as you type. Debounced, and
|
||||||
|
aborted on each keystroke so a slow response to `rad` can't land after
|
||||||
|
`radiohead` and repopulate a stale list.
|
||||||
|
- **30-second previews.** Deezer's payload includes a preview clip, so you can
|
||||||
|
hear a track *before* committing a Soulseek search to it — the cheapest
|
||||||
|
possible guard against importing the wrong take. It is not a library player;
|
||||||
|
that's Navidrome's job, and its native clients do it better.
|
||||||
|
- **Album and artist pages.** Click through to a full track list with real
|
||||||
|
track numbers, or an artist's top tracks and discography. `Get all` queues a
|
||||||
|
whole album in one request.
|
||||||
|
- **The accent colour is sampled from the artwork.** The dominant colour of
|
||||||
|
whatever is on screen drives the entire palette. Deezer's CDN sends
|
||||||
|
`access-control-allow-origin: *`, which is what makes client-side canvas
|
||||||
|
sampling possible with no image proxy and no server CPU.
|
||||||
|
|
||||||
|
### Album track numbers
|
||||||
|
|
||||||
|
`/api/album/{id}` calls Deezer twice: once for the header, once for
|
||||||
|
`/album/{id}/tracks`. The track list embedded in the first response has no
|
||||||
|
`track_position` — taking the list index instead would misnumber every
|
||||||
|
multi-disc release, filing disc 2 track 1 as `18 - Birthday.flac`. The
|
||||||
|
dedicated endpoint is the only source that carries `track_position` and
|
||||||
|
`disk_number`, and those numbers go straight into the filename and the tags.
|
||||||
|
|
||||||
|
### Developing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — the API
|
||||||
|
uvicorn app:app --port 5041
|
||||||
|
|
||||||
|
# terminal 2 — the UI, proxying /api and /auth to the above
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`next dev` serves on :3000 and rewrites `/api/*` and `/auth/*` to the FastAPI
|
||||||
|
process, so the browser sees one origin and cookies behave exactly as they do
|
||||||
|
in production. `npm run build` writes `web/out/`, which is what ships.
|
||||||
|
|
||||||
## Bulk import
|
## Bulk import
|
||||||
|
|
||||||
Open **Import a list**, paste, hit **Queue all**. These all parse:
|
Open **Import a list**, paste, hit **Queue all**. These all parse:
|
||||||
@@ -143,10 +199,27 @@ deploy quietly serving an unauthenticated download client to the internet.
|
|||||||
immediately rather than whenever their session expires.
|
immediately rather than whenever their session expires.
|
||||||
- **OAuth `state`** is generated per login and compared with
|
- **OAuth `state`** is generated per login and compared with
|
||||||
`secrets.compare_digest`, so a callback you didn't initiate is rejected.
|
`secrets.compare_digest`, so a callback you didn't initiate is rejected.
|
||||||
- **Strict CSP** — `default-src 'self'`, no `unsafe-inline`. This is why the
|
- **Strict CSP, with hashed scripts.** `script-src 'self'` plus a SHA-256 for
|
||||||
stylesheet and script are separate files instead of inline in the page: an
|
each inline script Next emits. Those hashes are computed *at startup* by
|
||||||
injected `<script>` cannot execute. Plus `frame-ancestors 'none'`,
|
reading the built page, so a rebuild updates them with no maintenance and no
|
||||||
`base-uri 'none'`, `nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
per-request HTML rewriting — the usual nonce dance is unnecessary precisely
|
||||||
|
because a static export never varies between requests.
|
||||||
|
`style-src-attr 'unsafe-inline'` is the one concession: Framer Motion writes
|
||||||
|
its pre-hydration `initial` state into `style=""`, and without it every
|
||||||
|
animated element flashes in at full opacity. That directive permits style
|
||||||
|
*attributes* only — injected `<style>` blocks and remote stylesheets are
|
||||||
|
still blocked, and CSS-based exfiltration goes through `url()`, which
|
||||||
|
`img-src` already pins to two hosts.
|
||||||
|
- **`img-src`/`media-src` name Deezer's CDN explicitly** rather than allowing
|
||||||
|
`https:` wholesale. Under a blanket `https:`, injected markup can beacon to
|
||||||
|
any host by requesting an image from it — a working exfiltration channel even
|
||||||
|
with `connect-src` locked to `'self'`.
|
||||||
|
- Plus `frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`,
|
||||||
|
`nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
|
||||||
|
- **The JS bundle is behind the session too.** There is no local login page to
|
||||||
|
style — an unauthenticated visitor is redirected straight to GitHub and never
|
||||||
|
receives the HTML — so `/_next/*` requires a cookie like everything else, and
|
||||||
|
doesn't hand out a map of the app to whoever finds the URL.
|
||||||
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
|
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
|
||||||
login page doesn't hand out a map of every endpoint.
|
login page doesn't hand out a map of every endpoint.
|
||||||
- **Denied logins are logged** with the GitHub username — on an
|
- **Denied logins are logged** with the GitHub username — on an
|
||||||
@@ -250,13 +323,19 @@ package page → Package settings → Change visibility.
|
|||||||
| endpoint | purpose |
|
| endpoint | purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /` | web UI |
|
| `GET /` | web UI |
|
||||||
| `GET /api/search?q=` | Deezer track search |
|
| `GET /api/search?q=` | track search, enriched with year and genre |
|
||||||
|
| `GET /api/suggest?q=` | autocomplete — songs, albums, artists |
|
||||||
|
| `GET /api/album/{id}` | album header plus its full track list |
|
||||||
|
| `GET /api/artist/{id}` | artist, top tracks, discography |
|
||||||
| `POST /api/request` | queue a track |
|
| `POST /api/request` | queue a track |
|
||||||
|
| `POST /api/request/batch` | queue many known tracks (a whole album) |
|
||||||
| `POST /api/import` | queue many from pasted text |
|
| `POST /api/import` | queue many from pasted text |
|
||||||
| `POST /api/retry/{id}` | requeue a failed job |
|
| `POST /api/retry/{id}` | requeue a failed job |
|
||||||
| `GET /api/jobs` | request history and state |
|
| `GET /api/jobs` | request history and state |
|
||||||
| `GET /api/health` | slskd reachability, mount writability |
|
| `GET /api/health` | slskd reachability, mount writability |
|
||||||
|
|
||||||
|
Everything except `/healthz` requires a session.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
Every push to `main` publishes `:latest` and `:sha-<short>`. Tagging `vX.Y.Z`
|
Every push to `main` publishes `:latest` and `:sha-<short>`. Tagging `vX.Y.Z`
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ file on disk, two names — the same trick the *arrs use.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import difflib
|
import difflib
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
@@ -201,8 +202,15 @@ def session_user(request: Request) -> str | None:
|
|||||||
return None
|
return None
|
||||||
# Re-check the allowlist on every request, so removing someone from
|
# Re-check the allowlist on every request, so removing someone from
|
||||||
# MUSE_ALLOWED_USERS locks them out immediately instead of whenever their
|
# MUSE_ALLOWED_USERS locks them out immediately instead of whenever their
|
||||||
# session happens to expire.
|
# session happens to expire. Same for the per-user disable flag: a
|
||||||
return row["username"] if row["username"].lower() in ALLOWED_USERS else None
|
# revocation that only took effect on expiry would be a revocation in name
|
||||||
|
# only.
|
||||||
|
if row["username"].lower() not in ALLOWED_USERS:
|
||||||
|
return None
|
||||||
|
with db() as c:
|
||||||
|
u = c.execute("SELECT disabled FROM users WHERE username=?",
|
||||||
|
(row["username"],)).fetchone()
|
||||||
|
return None if u and u["disabled"] else row["username"]
|
||||||
|
|
||||||
|
|
||||||
SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
@@ -250,6 +258,49 @@ def init_db():
|
|||||||
expires_at TEXT NOT NULL
|
expires_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# Users exist as rows even while the allowlist is a single name in an
|
||||||
|
# env var. Roles and quotas are read from here from the start, so
|
||||||
|
# opening this up to other people later is an INSERT and a config
|
||||||
|
# change rather than a schema migration against live job history.
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY COLLATE NOCASE,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'github',
|
||||||
|
role TEXT NOT NULL DEFAULT 'user', -- admin|user|guest
|
||||||
|
display TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
quota INTEGER NOT NULL DEFAULT 0, -- requests/month, 0 = unlimited
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_seen TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Added after the first release, so existing databases need it grafted
|
||||||
|
# on rather than recreated -- job history is the whole point of the file.
|
||||||
|
cols = {r["name"] for r in c.execute("PRAGMA table_info(jobs)").fetchall()}
|
||||||
|
for col, decl in (("requested_by", "TEXT"),
|
||||||
|
("cover", "TEXT"),
|
||||||
|
("genre", "TEXT"),
|
||||||
|
("duration", "INTEGER")):
|
||||||
|
if col not in cols:
|
||||||
|
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||||||
|
c.execute("CREATE INDEX IF NOT EXISTS jobs_by_user ON jobs (requested_by)")
|
||||||
|
c.execute("CREATE INDEX IF NOT EXISTS jobs_by_state ON jobs (state)")
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_user(username: str, avatar: str = "", display: str = "") -> sqlite3.Row:
|
||||||
|
"""Record the sign-in. The first user to ever log in becomes admin --
|
||||||
|
there is no bootstrap ceremony and no default password to forget."""
|
||||||
|
with db() as c:
|
||||||
|
first = c.execute("SELECT COUNT(*) n FROM users").fetchone()["n"] == 0
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO users (username,provider,role,display,avatar,last_seen) "
|
||||||
|
"VALUES (?,?,?,?,?,datetime('now')) "
|
||||||
|
"ON CONFLICT(username) DO UPDATE SET "
|
||||||
|
" last_seen=datetime('now'), avatar=excluded.avatar, "
|
||||||
|
" display=COALESCE(NULLIF(excluded.display,''), users.display)",
|
||||||
|
(username, "github", "admin" if first else "user", display, avatar))
|
||||||
|
return c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def set_state(job_id: str, state: str, detail: str = "", **cols):
|
def set_state(job_id: str, state: str, detail: str = "", **cols):
|
||||||
@@ -525,6 +576,56 @@ async def lifespan(_: FastAPI):
|
|||||||
t.cancel()
|
t.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────── static front end / CSP ───────────────────────
|
||||||
|
#
|
||||||
|
# The UI is a Next.js static export: `next build` writes plain files into
|
||||||
|
# web/out and this process serves them, so the deployment stays one container,
|
||||||
|
# one process, one healthcheck, with no Node runtime in the image.
|
||||||
|
#
|
||||||
|
# That export contains inline <script> blocks (React's hydration payload),
|
||||||
|
# which `script-src 'self'` blocks outright. The usual fix is a per-request
|
||||||
|
# nonce, but a STATIC export has a better option: the scripts never change
|
||||||
|
# between requests, so their SHA-256 can go straight into the policy. Hashing
|
||||||
|
# them at startup means the CSP needs no per-request work, no HTML rewriting,
|
||||||
|
# and no maintenance when the bundle changes -- rebuild and the hashes follow.
|
||||||
|
WEB = Path(__file__).parent / "web" / "out"
|
||||||
|
|
||||||
|
_INLINE_SCRIPT = re.compile(rb"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def script_hashes() -> list[str]:
|
||||||
|
out: set[str] = set()
|
||||||
|
for page in ("index.html", "404.html"):
|
||||||
|
f = WEB / page
|
||||||
|
if not f.exists():
|
||||||
|
continue
|
||||||
|
for body in _INLINE_SCRIPT.findall(f.read_bytes()):
|
||||||
|
digest = base64.b64encode(hashlib.sha256(body).digest()).decode()
|
||||||
|
out.add(f"'sha256-{digest}'")
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_HASHES = " ".join(script_hashes())
|
||||||
|
|
||||||
|
# Framer Motion writes its pre-hydration `initial` state into style="" on the
|
||||||
|
# server-rendered markup, so those attributes have to be allowed or every
|
||||||
|
# animated element flashes in at full opacity before hydration corrects it.
|
||||||
|
#
|
||||||
|
# style-src-attr is the narrow lever for that: it permits style ATTRIBUTES
|
||||||
|
# while style-src 'self' still blocks injected <style> blocks and remote
|
||||||
|
# stylesheets. It is not a free pass either -- CSS-based exfiltration goes
|
||||||
|
# through url(), and img-src below already limits where those can point.
|
||||||
|
CSP = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
f"script-src 'self' {SCRIPT_HASHES}; "
|
||||||
|
"style-src 'self'; style-src-attr 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data: https://*.dzcdn.net https://*.githubusercontent.com; "
|
||||||
|
"media-src 'self' https://*.dzcdn.net; "
|
||||||
|
"connect-src 'self'; font-src 'self'; object-src 'none'; "
|
||||||
|
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="muse",
|
title="muse",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -538,8 +639,13 @@ app = FastAPI(
|
|||||||
async def guard(request: Request, call_next):
|
async def guard(request: Request, call_next):
|
||||||
"""Require a session for everything except PUBLIC_PATHS, and stamp
|
"""Require a session for everything except PUBLIC_PATHS, and stamp
|
||||||
security headers on every response."""
|
security headers on every response."""
|
||||||
|
# /_next/* is NOT public. Unlike the old hand-written page there is no
|
||||||
|
# local login screen to style — an unauthenticated visitor is redirected
|
||||||
|
# straight to GitHub and never receives our HTML — so the bundle can sit
|
||||||
|
# behind the session too, and not hand out a map of the app to anyone who
|
||||||
|
# finds the URL. It loads fine after the callback, cookie in hand.
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if AUTH_MODE == "off" or path in PUBLIC_PATHS or path.startswith("/static/"):
|
if AUTH_MODE == "off" or path in PUBLIC_PATHS:
|
||||||
resp = await call_next(request)
|
resp = await call_next(request)
|
||||||
elif session_user(request):
|
elif session_user(request):
|
||||||
resp = await call_next(request)
|
resp = await call_next(request)
|
||||||
@@ -549,15 +655,12 @@ async def guard(request: Request, call_next):
|
|||||||
else:
|
else:
|
||||||
resp = RedirectResponse("/auth/login", status_code=302)
|
resp = RedirectResponse("/auth/login", status_code=302)
|
||||||
|
|
||||||
# Strict CSP is why the stylesheet and script live in separate files rather
|
# See CSP above. img-src and media-src name Deezer's CDN explicitly rather
|
||||||
# than inline in index.html: with 'self' only and no 'unsafe-inline', an
|
# than allowing https: wholesale — under a blanket https: any injected
|
||||||
# injected <script> cannot execute. img-src allows Deezer's cover CDN over
|
# markup could beacon to an arbitrary host by requesting an image from it,
|
||||||
# https; connect-src is self only, so nothing can exfiltrate to a third
|
# which is a working exfiltration channel even with connect-src locked to
|
||||||
# party from this page.
|
# 'self'. Artwork comes from dzcdn.net and avatars from githubusercontent.
|
||||||
resp.headers["Content-Security-Policy"] = (
|
resp.headers["Content-Security-Policy"] = CSP
|
||||||
"default-src 'self'; img-src 'self' https: data:; "
|
|
||||||
"style-src 'self'; script-src 'self'; connect-src 'self'; "
|
|
||||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
||||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
resp.headers["X-Frame-Options"] = "DENY"
|
resp.headers["X-Frame-Options"] = "DENY"
|
||||||
resp.headers["Referrer-Policy"] = "no-referrer"
|
resp.headers["Referrer-Policy"] = "no-referrer"
|
||||||
@@ -619,7 +722,8 @@ async def auth_callback(request: Request, code: str = "", state: str = ""):
|
|||||||
"Accept": "application/vnd.github+json"})
|
"Accept": "application/vnd.github+json"})
|
||||||
if u.status_code != 200:
|
if u.status_code != 200:
|
||||||
return JSONResponse({"error": "github user lookup failed"}, status_code=401)
|
return JSONResponse({"error": "github user lookup failed"}, status_code=401)
|
||||||
login = (u.json() or {}).get("login") or ""
|
profile = u.json() or {}
|
||||||
|
login = profile.get("login") or ""
|
||||||
|
|
||||||
if login.lower() not in ALLOWED_USERS:
|
if login.lower() not in ALLOWED_USERS:
|
||||||
# Log the attempt: on an internet-facing box this is the signal that
|
# Log the attempt: on an internet-facing box this is the signal that
|
||||||
@@ -627,7 +731,13 @@ async def auth_callback(request: Request, code: str = "", state: str = ""):
|
|||||||
log.warning("auth: DENIED github user %r (not in MUSE_ALLOWED_USERS)", login)
|
log.warning("auth: DENIED github user %r (not in MUSE_ALLOWED_USERS)", login)
|
||||||
return JSONResponse({"error": "not authorised"}, status_code=403)
|
return JSONResponse({"error": "not authorised"}, status_code=403)
|
||||||
|
|
||||||
log.info("auth: signed in %s", login)
|
row = upsert_user(login, profile.get("avatar_url") or "",
|
||||||
|
profile.get("name") or "")
|
||||||
|
if row and row["disabled"]:
|
||||||
|
log.warning("auth: DENIED %r (account disabled)", login)
|
||||||
|
return JSONResponse({"error": "account disabled"}, status_code=403)
|
||||||
|
|
||||||
|
log.info("auth: signed in %s (%s)", login, row["role"] if row else "user")
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
_set_cookie(resp, SESSION_COOKIE, new_session(login), SESSION_DAYS * 86400)
|
_set_cookie(resp, SESSION_COOKIE, new_session(login), SESSION_DAYS * 86400)
|
||||||
resp.delete_cookie(STATE_COOKIE, path="/")
|
resp.delete_cookie(STATE_COOKIE, path="/")
|
||||||
@@ -647,7 +757,13 @@ async def auth_logout(request: Request):
|
|||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
async def me(request: Request):
|
async def me(request: Request):
|
||||||
return {"user": session_user(request), "auth": AUTH_MODE}
|
who = session_user(request)
|
||||||
|
if not who:
|
||||||
|
return {"user": None, "auth": AUTH_MODE}
|
||||||
|
with db() as c:
|
||||||
|
u = c.execute("SELECT username,role,display,avatar,quota FROM users "
|
||||||
|
"WHERE username=?", (who,)).fetchone()
|
||||||
|
return {"user": who, "auth": AUTH_MODE, **(dict(u) if u else {"role": "user"})}
|
||||||
|
|
||||||
|
|
||||||
class RequestBody(BaseModel):
|
class RequestBody(BaseModel):
|
||||||
@@ -657,6 +773,90 @@ class RequestBody(BaseModel):
|
|||||||
year: str | None = None
|
year: str | None = None
|
||||||
track: int | None = None
|
track: int | None = None
|
||||||
deezer_id: int | None = None
|
deezer_id: int | None = None
|
||||||
|
cover: str | None = None
|
||||||
|
genre: str | None = None
|
||||||
|
length: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Album metadata (release year, genre, label) is not in the track search
|
||||||
|
# payload, so it costs one extra call per distinct album. Cached forever on
|
||||||
|
# purpose: a release date and a genre are immutable, so there is nothing to
|
||||||
|
# invalidate, and repeat searches for the same artist stop hitting Deezer at
|
||||||
|
# all. Trimmed only to stop it growing without bound on a long-lived process.
|
||||||
|
_album_cache: dict[int, dict] = {}
|
||||||
|
_album_lock = asyncio.Semaphore(6) # Deezer rate-limits ~50 req/5s per IP
|
||||||
|
|
||||||
|
|
||||||
|
async def album_meta(album_id) -> dict:
|
||||||
|
if not album_id:
|
||||||
|
return {}
|
||||||
|
key = int(album_id)
|
||||||
|
if key in _album_cache:
|
||||||
|
return _album_cache[key]
|
||||||
|
async with _album_lock:
|
||||||
|
if key in _album_cache: # filled while we waited for the slot
|
||||||
|
return _album_cache[key]
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.get(f"{DEEZER}/album/{key}")
|
||||||
|
d = r.json() if r.status_code == 200 else {}
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("album %s lookup failed: %s", key, e)
|
||||||
|
return {}
|
||||||
|
genres = [g.get("name") for g in (d.get("genres") or {}).get("data", []) if g.get("name")]
|
||||||
|
meta = {
|
||||||
|
"year": (d.get("release_date") or "")[:4] or None,
|
||||||
|
"genre": genres[0] if genres else None,
|
||||||
|
"genres": genres,
|
||||||
|
"label": d.get("label"),
|
||||||
|
"nb_tracks": d.get("nb_tracks"),
|
||||||
|
"record_type": d.get("record_type"),
|
||||||
|
}
|
||||||
|
if len(_album_cache) > 5000:
|
||||||
|
for k in list(_album_cache)[:1000]:
|
||||||
|
_album_cache.pop(k, None)
|
||||||
|
_album_cache[key] = meta
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def track_row(t: dict) -> dict:
|
||||||
|
"""One Deezer track object -> the shape the UI and the job table use."""
|
||||||
|
artist = t.get("artist") or {}
|
||||||
|
album = t.get("album") or {}
|
||||||
|
return {
|
||||||
|
"deezer_id": t.get("id"),
|
||||||
|
"title": t.get("title") or "",
|
||||||
|
"artist": artist.get("name") or "",
|
||||||
|
"artist_id": artist.get("id"),
|
||||||
|
"artist_pic": artist.get("picture_medium"),
|
||||||
|
"album": album.get("title") or "",
|
||||||
|
"album_id": album.get("id"),
|
||||||
|
"cover": album.get("cover_medium"),
|
||||||
|
"cover_big": album.get("cover_big") or album.get("cover_medium"),
|
||||||
|
"length": (t.get("duration") or 0) * 1000,
|
||||||
|
# 30-second clip from Deezer's CDN. This is what lets you confirm you
|
||||||
|
# picked the right recording BEFORE committing a Soulseek search to it
|
||||||
|
# -- the single most useful thing in the payload for avoiding a wrong
|
||||||
|
# import, and it costs nothing extra.
|
||||||
|
"preview": t.get("preview") or None,
|
||||||
|
"explicit": bool(t.get("explicit_lyrics")),
|
||||||
|
"isrc": t.get("isrc"),
|
||||||
|
"rank": t.get("rank") or 0,
|
||||||
|
"year": None, "genre": None, "track": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def add_album_meta(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Fill year and genre on a result list, concurrently and de-duplicated."""
|
||||||
|
ids = {r["album_id"] for r in rows if r.get("album_id")}
|
||||||
|
if not ids:
|
||||||
|
return rows
|
||||||
|
metas = dict(zip(ids, await asyncio.gather(*(album_meta(i) for i in ids))))
|
||||||
|
for r in rows:
|
||||||
|
m = metas.get(r.get("album_id")) or {}
|
||||||
|
r["year"] = r["year"] or m.get("year")
|
||||||
|
r["genre"] = r["genre"] or m.get("genre")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
||||||
@@ -674,7 +874,6 @@ async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
|||||||
for t in data.get("data", []):
|
for t in data.get("data", []):
|
||||||
title = t.get("title") or ""
|
title = t.get("title") or ""
|
||||||
artist = (t.get("artist") or {}).get("name") or ""
|
artist = (t.get("artist") or {}).get("name") or ""
|
||||||
album = (t.get("album") or {}).get("title") or ""
|
|
||||||
|
|
||||||
s = float(t.get("rank") or 0) / 10_000 # Deezer popularity, 0-~100
|
s = float(t.get("rank") or 0) / 10_000 # Deezer popularity, 0-~100
|
||||||
|
|
||||||
@@ -702,16 +901,7 @@ async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
|||||||
# above and pick a different song.
|
# above and pick a different song.
|
||||||
if ("(" in title or "[" in title) and "(" not in q:
|
if ("(" in title or "[" in title) and "(" not in q:
|
||||||
s -= 40
|
s -= 40
|
||||||
scored.append((s, {
|
scored.append((s, track_row(t)))
|
||||||
"deezer_id": t.get("id"),
|
|
||||||
"title": title,
|
|
||||||
"artist": artist,
|
|
||||||
"album": album,
|
|
||||||
"length": (t.get("duration") or 0) * 1000,
|
|
||||||
"cover": (t.get("album") or {}).get("cover_medium"),
|
|
||||||
# filled in at request time from /track/{id}
|
|
||||||
"year": None, "track": None,
|
|
||||||
}))
|
|
||||||
|
|
||||||
seen, out = set(), []
|
seen, out = set(), []
|
||||||
for _, item in sorted(scored, key=lambda x: -x[0]):
|
for _, item in sorted(scored, key=lambda x: -x[0]):
|
||||||
@@ -725,20 +915,142 @@ async def deezer_search(q: str, limit: int = 25) -> list[dict]:
|
|||||||
|
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
async def search(q: str, limit: int = 25):
|
async def search(q: str, limit: int = 25):
|
||||||
"""Track search. Returns individual songs, not albums — the whole point."""
|
"""Track search. Returns individual songs, not albums — the whole point.
|
||||||
return {"results": await deezer_search(q, limit)}
|
|
||||||
|
Year and genre are grafted on afterwards because Deezer's track search
|
||||||
|
omits both. Enrichment happens only here, not in deezer_search itself, so
|
||||||
|
the bulk-import path stays one HTTP call per line.
|
||||||
|
"""
|
||||||
|
return {"results": await add_album_meta(await deezer_search(q, limit))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/suggest")
|
||||||
|
async def suggest(q: str):
|
||||||
|
"""Autocomplete. Deliberately does NOT enrich or re-rank: this fires on
|
||||||
|
every few keystrokes, so it has to be one upstream call and nothing else.
|
||||||
|
Deezer's own autocomplete ordering is good enough for a dropdown, and the
|
||||||
|
real ranking still happens when the search is actually submitted."""
|
||||||
|
q = q.strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return {"tracks": [], "albums": [], "artists": []}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8) as c:
|
||||||
|
r = await c.get(f"{DEEZER}/search/autocomplete",
|
||||||
|
params={"q": q, "limit": 5})
|
||||||
|
d = r.json() if r.status_code == 200 else {}
|
||||||
|
except Exception:
|
||||||
|
return {"tracks": [], "albums": [], "artists": []}
|
||||||
|
|
||||||
|
def rows(key):
|
||||||
|
return (d.get(key) or {}).get("data", []) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tracks": [{"deezer_id": t.get("id"), "title": t.get("title"),
|
||||||
|
"artist": (t.get("artist") or {}).get("name"),
|
||||||
|
"cover": (t.get("album") or {}).get("cover_small"),
|
||||||
|
"preview": t.get("preview")}
|
||||||
|
for t in rows("tracks")[:5]],
|
||||||
|
"albums": [{"album_id": a.get("id"), "title": a.get("title"),
|
||||||
|
"artist": (a.get("artist") or {}).get("name"),
|
||||||
|
"cover": a.get("cover_small"),
|
||||||
|
"nb_tracks": a.get("nb_tracks")}
|
||||||
|
for a in rows("albums")[:3]],
|
||||||
|
"artists": [{"artist_id": a.get("id"), "name": a.get("name"),
|
||||||
|
"cover": a.get("picture_small"),
|
||||||
|
"nb_album": a.get("nb_album")}
|
||||||
|
for a in rows("artists")[:3]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/album/{album_id}")
|
||||||
|
async def album_detail(album_id: int):
|
||||||
|
"""Full track list for an album, so 'Get all' can queue it in one go."""
|
||||||
|
# Two calls, not one. /album/{id} embeds a track list, but those nested
|
||||||
|
# objects have no track_position -- and taking the list index instead would
|
||||||
|
# misnumber every multi-disc release, writing "13 - ..." for disc 2 track 1.
|
||||||
|
# /album/{id}/tracks is the only source that carries track_position and
|
||||||
|
# disk_number, and those numbers end up in the filename and the tags.
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
r, tr = await asyncio.gather(
|
||||||
|
c.get(f"{DEEZER}/album/{album_id}"),
|
||||||
|
c.get(f"{DEEZER}/album/{album_id}/tracks", params={"limit": 300}))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"deezer: {e}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise HTTPException(404, "album not found")
|
||||||
|
d = r.json()
|
||||||
|
|
||||||
|
artist = (d.get("artist") or {}).get("name") or ""
|
||||||
|
year = (d.get("release_date") or "")[:4] or None
|
||||||
|
genres = [g.get("name") for g in (d.get("genres") or {}).get("data", []) if g.get("name")]
|
||||||
|
raw = (tr.json().get("data", []) if tr.status_code == 200
|
||||||
|
else (d.get("tracks") or {}).get("data", []))
|
||||||
|
tracks = []
|
||||||
|
for t in raw:
|
||||||
|
row = track_row(t)
|
||||||
|
# These nested track objects carry no album of their own, so inherit
|
||||||
|
# what the parent already told us rather than looking it up again.
|
||||||
|
row.update(album=d.get("title") or "", album_id=d.get("id"),
|
||||||
|
cover=d.get("cover_medium"), cover_big=d.get("cover_big"),
|
||||||
|
artist=row["artist"] or artist, year=year,
|
||||||
|
genre=genres[0] if genres else None,
|
||||||
|
track=t.get("track_position"),
|
||||||
|
disc=t.get("disk_number"))
|
||||||
|
tracks.append(row)
|
||||||
|
|
||||||
|
return {"album": {"album_id": d.get("id"), "title": d.get("title"),
|
||||||
|
"artist": artist, "artist_id": (d.get("artist") or {}).get("id"),
|
||||||
|
"cover_big": d.get("cover_big"), "cover": d.get("cover_medium"),
|
||||||
|
"year": year, "genres": genres, "label": d.get("label"),
|
||||||
|
"nb_tracks": d.get("nb_tracks"),
|
||||||
|
"record_type": d.get("record_type"),
|
||||||
|
"duration": d.get("duration")},
|
||||||
|
"tracks": tracks}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/artist/{artist_id}")
|
||||||
|
async def artist_detail(artist_id: int):
|
||||||
|
"""Artist header plus their top tracks and albums."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as c:
|
||||||
|
info, top, albums = await asyncio.gather(
|
||||||
|
c.get(f"{DEEZER}/artist/{artist_id}"),
|
||||||
|
c.get(f"{DEEZER}/artist/{artist_id}/top", params={"limit": 15}),
|
||||||
|
c.get(f"{DEEZER}/artist/{artist_id}/albums", params={"limit": 24}))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"deezer: {e}")
|
||||||
|
if info.status_code != 200:
|
||||||
|
raise HTTPException(404, "artist not found")
|
||||||
|
a = info.json()
|
||||||
|
tracks = [track_row(t) for t in (top.json().get("data", []) if top.status_code == 200 else [])]
|
||||||
|
await add_album_meta(tracks)
|
||||||
|
return {
|
||||||
|
"artist": {"artist_id": a.get("id"), "name": a.get("name"),
|
||||||
|
"picture": a.get("picture_big"), "nb_fan": a.get("nb_fan"),
|
||||||
|
"nb_album": a.get("nb_album")},
|
||||||
|
"tracks": tracks,
|
||||||
|
"albums": [{"album_id": x.get("id"), "title": x.get("title"),
|
||||||
|
"cover": x.get("cover_medium"), "cover_big": x.get("cover_big"),
|
||||||
|
"year": (x.get("release_date") or "")[:4] or None,
|
||||||
|
"record_type": x.get("record_type"),
|
||||||
|
"nb_tracks": x.get("nb_tracks")}
|
||||||
|
for x in (albums.json().get("data", []) if albums.status_code == 200 else [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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) -> str:
|
track=None, deezer_id=None, requested_by=None,
|
||||||
|
cover=None, genre=None, duration=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())
|
||||||
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) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
"recording_mbid,release_mbid,requested_by,cover,genre,duration) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(job_id, "queued", "", artist, title, album, year, track,
|
(job_id, "queued", "", artist, title, album, year, track,
|
||||||
str(deezer_id or ""), None))
|
str(deezer_id or ""), None, requested_by, cover, genre, duration))
|
||||||
await work_q.put(job_id)
|
await work_q.put(job_id)
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
@@ -767,12 +1079,36 @@ async def enrich(deezer_id) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/request")
|
@app.post("/api/request")
|
||||||
async def request_track(body: RequestBody):
|
async def request_track(body: RequestBody, request: Request):
|
||||||
e = await enrich(body.deezer_id)
|
e = await enrich(body.deezer_id)
|
||||||
return {"id": await enqueue_job(
|
return {"id": await enqueue_job(
|
||||||
e.get("artist") or body.artist, body.title,
|
e.get("artist") or body.artist, body.title,
|
||||||
e.get("album") or body.album, e.get("year") or body.year,
|
e.get("album") or body.album, e.get("year") or body.year,
|
||||||
e.get("track") or body.track, body.deezer_id)}
|
e.get("track") or body.track, body.deezer_id,
|
||||||
|
requested_by=session_user(request), cover=body.cover,
|
||||||
|
genre=body.genre, duration=body.length)}
|
||||||
|
|
||||||
|
|
||||||
|
class BatchBody(BaseModel):
|
||||||
|
tracks: list[RequestBody]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/request/batch")
|
||||||
|
async def request_batch(body: BatchBody, request: Request):
|
||||||
|
"""Queue a whole album at once.
|
||||||
|
|
||||||
|
Unlike /api/request this skips the per-track /track/{id} enrichment: the
|
||||||
|
caller got these rows from /api/album/{id}, which already carries the
|
||||||
|
album, year and track position for every one of them. Re-fetching would be
|
||||||
|
nb_tracks extra calls to learn what we were just told.
|
||||||
|
"""
|
||||||
|
who = session_user(request)
|
||||||
|
ids = [await enqueue_job(t.artist, t.title, t.album, t.year, t.track,
|
||||||
|
t.deezer_id, requested_by=who, cover=t.cover,
|
||||||
|
genre=t.genre, duration=t.length)
|
||||||
|
for t in body.tracks]
|
||||||
|
log.info("batch: queued %d track(s) for %s", len(ids), who or "anon")
|
||||||
|
return {"queued": len(ids), "ids": ids, "pending": work_q.qsize()}
|
||||||
|
|
||||||
|
|
||||||
class ImportBody(BaseModel):
|
class ImportBody(BaseModel):
|
||||||
@@ -803,7 +1139,7 @@ async def expand_url(kind: str, ident: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/import")
|
@app.post("/api/import")
|
||||||
async def import_list(body: ImportBody):
|
async def import_list(body: ImportBody, request: Request):
|
||||||
"""Paste a list, get it all queued.
|
"""Paste a list, get it all queued.
|
||||||
|
|
||||||
Each line is resolved against Deezer and the top-ranked hit is taken
|
Each line is resolved against Deezer and the top-ranked hit is taken
|
||||||
@@ -839,8 +1175,9 @@ async def import_list(body: ImportBody):
|
|||||||
jid = await enqueue_job(
|
jid = await enqueue_job(
|
||||||
e.get("artist") or h["artist"], h["title"],
|
e.get("artist") or h["artist"], h["title"],
|
||||||
e.get("album") or h["album"], e.get("year"), e.get("track"),
|
e.get("album") or h["album"], e.get("year"), e.get("track"),
|
||||||
h["deezer_id"])
|
h["deezer_id"], requested_by=session_user(request),
|
||||||
queued.append({"id": jid, "line": line,
|
cover=h.get("cover"), duration=h.get("length"))
|
||||||
|
queued.append({"id": jid, "line": line, "cover": h.get("cover"),
|
||||||
"matched": f"{h['artist']} — {h['title']}"})
|
"matched": f"{h['artist']} — {h['title']}"})
|
||||||
|
|
||||||
log.info("import: %d queued, %d skipped", len(queued), len(skipped))
|
log.info("import: %d queued, %d skipped", len(queued), len(skipped))
|
||||||
@@ -889,12 +1226,23 @@ async def health():
|
|||||||
return {"ok": ok and detail["library_writable"], **detail}
|
return {"ok": ok and detail["library_writable"], **detail}
|
||||||
|
|
||||||
|
|
||||||
STATIC = Path(__file__).parent / "static"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(STATIC / "index.html")
|
f = WEB / "index.html"
|
||||||
|
if not f.exists():
|
||||||
|
raise HTTPException(500, "UI not built — run `npm run build` in web/")
|
||||||
|
return FileResponse(f)
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
# Next.js emits its immutable, content-hashed chunks under /_next/static.
|
||||||
|
#
|
||||||
|
# Guarded rather than mounted unconditionally so that importing this module
|
||||||
|
# does not require build artifacts: CI typechecks the API before the UI has
|
||||||
|
# been built, and tests should not have to run npm first. A deploy that really
|
||||||
|
# is missing its UI says so at startup and again on the first request, rather
|
||||||
|
# than silently serving 404s.
|
||||||
|
if (WEB / "_next").is_dir():
|
||||||
|
app.mount("/_next", StaticFiles(directory=WEB / "_next"), name="next")
|
||||||
|
else:
|
||||||
|
log.warning("web/out/_next is missing — the UI was not built. "
|
||||||
|
"API endpoints work; the web page will not.")
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
:root{
|
|
||||||
--bg:#0f1115; --panel:#171a21; --line:#252a34; --fg:#e7e9ee;
|
|
||||||
--dim:#8b93a3; --accent:#5b8cff; --ok:#3fb950; --err:#f85149; --warn:#d29922;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme:light){
|
|
||||||
:root{--bg:#f6f7f9;--panel:#fff;--line:#e2e5ea;--fg:#12151a;--dim:#5f6672;}
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);
|
|
||||||
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
||||||
header{position:sticky;top:0;z-index:5;background:var(--bg);
|
|
||||||
border-bottom:1px solid var(--line);padding:14px 18px}
|
|
||||||
.wrap{max-width:900px;margin:0 auto}
|
|
||||||
h1{margin:0 0 10px;font-size:17px;letter-spacing:.2px}
|
|
||||||
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
|
|
||||||
form{display:flex;gap:8px}
|
|
||||||
input{flex:1;padding:11px 13px;border-radius:9px;border:1px solid var(--line);
|
|
||||||
background:var(--panel);color:var(--fg);font-size:15px}
|
|
||||||
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
||||||
button{padding:11px 18px;border-radius:9px;border:0;background:var(--accent);
|
|
||||||
color:#fff;font-weight:600;cursor:pointer;font-size:15px}
|
|
||||||
button:disabled{opacity:.5;cursor:default}
|
|
||||||
main{padding:18px}
|
|
||||||
.row{display:flex;gap:12px;align-items:center;padding:10px;border-radius:10px;
|
|
||||||
border:1px solid var(--line);background:var(--panel);margin-bottom:8px}
|
|
||||||
.row img{width:48px;height:48px;border-radius:6px;object-fit:cover;
|
|
||||||
background:var(--line);flex:none}
|
|
||||||
.meta{flex:1;min-width:0}
|
|
||||||
.t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.s{color:var(--dim);font-size:13px;white-space:nowrap;overflow:hidden;
|
|
||||||
text-overflow:ellipsis}
|
|
||||||
.get{background:transparent;border:1px solid var(--accent);color:var(--accent);
|
|
||||||
padding:7px 14px;font-size:14px}
|
|
||||||
.get.done{border-color:var(--ok);color:var(--ok)}
|
|
||||||
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
|
|
||||||
margin:26px 0 10px;display:flex;align-items:center;gap:10px}
|
|
||||||
h2 .count{text-transform:none;letter-spacing:0;font-weight:400;font-size:12px}
|
|
||||||
details{margin-top:10px}
|
|
||||||
summary{cursor:pointer;color:var(--dim);font-size:13px;user-select:none}
|
|
||||||
summary:hover{color:var(--fg)}
|
|
||||||
textarea{width:100%;min-height:120px;margin-top:9px;padding:11px 13px;
|
|
||||||
border-radius:9px;border:1px solid var(--line);background:var(--panel);
|
|
||||||
color:var(--fg);font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
||||||
resize:vertical}
|
|
||||||
textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
||||||
.hint{color:var(--dim);font-size:12px;margin-top:7px}
|
|
||||||
.bar{height:3px;border-radius:2px;background:var(--line);overflow:hidden;
|
|
||||||
margin-top:10px}
|
|
||||||
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s}
|
|
||||||
.retry{background:transparent;border:1px solid var(--line);color:var(--dim);
|
|
||||||
padding:5px 11px;font-size:12px;font-weight:400}
|
|
||||||
.skipped{color:var(--err);font-size:12px;margin-top:8px}
|
|
||||||
.pill{font-size:12px;padding:2px 9px;border-radius:99px;border:1px solid var(--line);
|
|
||||||
color:var(--dim);white-space:nowrap}
|
|
||||||
.pill.imported{color:var(--ok);border-color:var(--ok)}
|
|
||||||
.pill.failed{color:var(--err);border-color:var(--err)}
|
|
||||||
.pill.downloading,.pill.searching,.pill.queued{color:var(--warn);border-color:var(--warn)}
|
|
||||||
.empty{color:var(--dim);padding:22px;text-align:center}
|
|
||||||
|
|
||||||
/* Inline style="" attributes are blocked by the strict CSP (style-src 'self'
|
|
||||||
with no 'unsafe-inline'), so anything that used to be inline lives here.
|
|
||||||
Setting .style from JS is unaffected -- CSP governs markup, not the CSSOM. */
|
|
||||||
.btnrow{display:flex;gap:9px;align-items:center;margin-top:9px}
|
|
||||||
.bar i{width:0}
|
|
||||||
.hidden{display:none}
|
|
||||||
.who{float:right;font-weight:400;font-size:12px;color:var(--dim)}
|
|
||||||
.who a{color:var(--dim);margin-left:9px}
|
|
||||||
.who a:hover{color:var(--fg)}
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
// Who is signed in, plus a logout link. Hidden entirely when MUSE_AUTH=off.
|
|
||||||
fetch('/api/me').then(r => r.json()).then(d => {
|
|
||||||
if (d.user) document.getElementById('who').innerHTML =
|
|
||||||
'signed in as ' + d.user.replace(/[&<>"]/g, '') + ' <a href="/auth/logout">log out</a>';
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
const $ = s => document.querySelector(s);
|
|
||||||
const esc = s => (s??'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
||||||
|
|
||||||
$('#f').onsubmit = async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const q = $('#q').value.trim();
|
|
||||||
if (!q) return;
|
|
||||||
$('#go').disabled = true;
|
|
||||||
$('#results').innerHTML = '<div class="empty">searching MusicBrainz…</div>';
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/search?q=' + encodeURIComponent(q));
|
|
||||||
const { results } = await r.json();
|
|
||||||
render(results);
|
|
||||||
} catch (err) {
|
|
||||||
$('#results').innerHTML = '<div class="empty">search failed: ' + esc(err.message) + '</div>';
|
|
||||||
}
|
|
||||||
$('#go').disabled = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
function render(list) {
|
|
||||||
if (!list || !list.length) {
|
|
||||||
$('#results').innerHTML = '<div class="empty">no matches</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('#results').innerHTML = list.map((x, i) => `
|
|
||||||
<div class="row">
|
|
||||||
${x.cover ? `<img src="${esc(x.cover)}" loading="lazy" onerror="this.style.visibility='hidden'">`
|
|
||||||
: '<img>'}
|
|
||||||
<div class="meta">
|
|
||||||
<div class="t">${esc(x.title)}</div>
|
|
||||||
<div class="s">${esc(x.artist)}${x.album ? ' · ' + esc(x.album) : ''}${x.year ? ' · ' + esc(x.year) : ''}</div>
|
|
||||||
</div>
|
|
||||||
<button class="get" data-i="${i}">Get</button>
|
|
||||||
</div>`).join('');
|
|
||||||
|
|
||||||
$('#results').querySelectorAll('.get').forEach(b => {
|
|
||||||
b.onclick = async () => {
|
|
||||||
const x = list[+b.dataset.i];
|
|
||||||
b.disabled = true; b.textContent = '…';
|
|
||||||
await fetch('/api/request', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(x)
|
|
||||||
});
|
|
||||||
b.textContent = 'Queued'; b.classList.add('done');
|
|
||||||
poll();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#imprun').onclick = async () => {
|
|
||||||
const text = $('#bulk').value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const b = $('#imprun');
|
|
||||||
b.disabled = true; b.textContent = 'Queueing…';
|
|
||||||
$('#impout').innerHTML = '';
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/import', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text })
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
$('#impout').innerHTML =
|
|
||||||
`<div class="hint">Queued ${d.queued.length}.</div>` +
|
|
||||||
(d.skipped.length
|
|
||||||
? `<div class="skipped">Skipped ${d.skipped.length}: ` +
|
|
||||||
d.skipped.map(s => esc(s.line) + ' (' + esc(s.why) + ')').join('; ') + '</div>'
|
|
||||||
: '');
|
|
||||||
if (d.queued.length) $('#bulk').value = '';
|
|
||||||
poll();
|
|
||||||
} catch (e) {
|
|
||||||
$('#impout').innerHTML = '<div class="skipped">import failed: ' + esc(e.message) + '</div>';
|
|
||||||
}
|
|
||||||
b.disabled = false; b.textContent = 'Queue all';
|
|
||||||
};
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
try {
|
|
||||||
const { jobs, counts, pending } = await (await fetch('/api/jobs')).json();
|
|
||||||
|
|
||||||
const active = (counts.queued || 0) + (counts.searching || 0) + (counts.downloading || 0);
|
|
||||||
const done = counts.imported || 0;
|
|
||||||
$('#qcount').textContent = active
|
|
||||||
? `${active} in flight${pending ? ` · ${pending} waiting` : ''}`
|
|
||||||
: (done ? `${done} imported` : '');
|
|
||||||
|
|
||||||
// Progress only means something during a batch.
|
|
||||||
const bar = $('#bar'), total = active + done;
|
|
||||||
bar.classList.toggle('hidden', !(active && total));
|
|
||||||
if (active && total)
|
|
||||||
bar.firstElementChild.style.width = (done / total * 100).toFixed(1) + '%';
|
|
||||||
|
|
||||||
$('#jobs').innerHTML = jobs.length ? jobs.map(j => `
|
|
||||||
<div class="row">
|
|
||||||
<div class="meta">
|
|
||||||
<div class="t">${esc(j.title)}</div>
|
|
||||||
<div class="s">${esc(j.artist)}${j.detail ? ' · ' + esc(j.detail) : ''}</div>
|
|
||||||
</div>
|
|
||||||
${j.state === 'failed'
|
|
||||||
? `<button class="retry" data-id="${esc(j.id)}">Retry</button>` : ''}
|
|
||||||
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
|
|
||||||
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
|
|
||||||
|
|
||||||
$('#jobs').querySelectorAll('.retry').forEach(b => {
|
|
||||||
b.onclick = async () => {
|
|
||||||
b.disabled = true;
|
|
||||||
await fetch('/api/retry/' + b.dataset.id, { method: 'POST' });
|
|
||||||
poll();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
poll();
|
|
||||||
setInterval(poll, 4000);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>muse</title>
|
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
|
||||||
<script src="/static/app.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header><div class="wrap">
|
|
||||||
<h1>muse <small>search a song → it lands in Navidrome</small>
|
|
||||||
<span class="who" id="who"></span></h1>
|
|
||||||
<form id="f">
|
|
||||||
<input id="q" placeholder="artist, song title…" autocomplete="off" autofocus>
|
|
||||||
<button id="go">Search</button>
|
|
||||||
</form>
|
|
||||||
<details id="imp">
|
|
||||||
<summary>Import a list — paste many songs at once</summary>
|
|
||||||
<textarea id="bulk" placeholder="One per line:
|
|
||||||
|
|
||||||
Radiohead - Creep
|
|
||||||
Juice WRLD - Lucid Dreams
|
|
||||||
Tory Lanez - The Color Violet
|
|
||||||
|
|
||||||
Or paste a Deezer album / playlist URL to expand it."></textarea>
|
|
||||||
<div class="btnrow">
|
|
||||||
<button id="imprun" type="button">Queue all</button>
|
|
||||||
<span class="hint" id="imphint">Top Deezer match is taken automatically — check the list below.</span>
|
|
||||||
</div>
|
|
||||||
<div id="impout"></div>
|
|
||||||
</details>
|
|
||||||
</div></header>
|
|
||||||
|
|
||||||
<main class="wrap">
|
|
||||||
<div id="results"></div>
|
|
||||||
<h2>Requests <span class="count" id="qcount"></span></h2>
|
|
||||||
<div class="bar hidden" id="bar"><i></i></div>
|
|
||||||
<div id="jobs"><div class="empty">nothing requested yet</div></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,174 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────────────────────────
|
||||||
|
muse design system
|
||||||
|
|
||||||
|
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
||||||
|
from whatever album is on screen; every tinted surface derives from them,
|
||||||
|
so the page takes on the palette of the music instead of a fixed brand
|
||||||
|
colour. JS writes those three numbers and nothing else.
|
||||||
|
─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||||
|
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||||
|
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
||||||
|
|
||||||
|
--color-ink: var(--fg);
|
||||||
|
--color-dim: var(--dim);
|
||||||
|
--color-edge: var(--edge);
|
||||||
|
--color-edge-hi: var(--edge-hi);
|
||||||
|
--color-faint: var(--faint);
|
||||||
|
|
||||||
|
--color-ok: #3ddc84;
|
||||||
|
--color-err: #ff6b6b;
|
||||||
|
--color-warn: #ffc95c;
|
||||||
|
|
||||||
|
--radius-glass: 14px;
|
||||||
|
--radius-panel: 28px;
|
||||||
|
|
||||||
|
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||||
|
|
||||||
|
--font-sans:
|
||||||
|
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||||
|
"Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--a-r: 120;
|
||||||
|
--a-g: 140;
|
||||||
|
--a-b: 255;
|
||||||
|
|
||||||
|
--bg: #08090d;
|
||||||
|
--fg: #f2f4f9;
|
||||||
|
--dim: #9aa3b6;
|
||||||
|
--faint: rgb(255 255 255 / 0.07);
|
||||||
|
--glass: rgb(255 255 255 / 0.055);
|
||||||
|
--glass-hi: rgb(255 255 255 / 0.085);
|
||||||
|
--edge: rgb(255 255 255 / 0.1);
|
||||||
|
--edge-hi: rgb(255 255 255 / 0.17);
|
||||||
|
--sheet: rgb(16 18 26 / 0.86);
|
||||||
|
--header: rgb(8 9 13 / 0.62);
|
||||||
|
--scrim: rgb(8 9 13 / 0.74);
|
||||||
|
--scrim-2: rgb(8 9 13 / 0.4);
|
||||||
|
--blur: saturate(170%) blur(22px);
|
||||||
|
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f4f5f8;
|
||||||
|
--fg: #0e1116;
|
||||||
|
--dim: #5a6274;
|
||||||
|
--faint: rgb(10 14 25 / 0.07);
|
||||||
|
--glass: rgb(255 255 255 / 0.7);
|
||||||
|
--glass-hi: rgb(255 255 255 / 0.88);
|
||||||
|
--edge: rgb(10 14 25 / 0.09);
|
||||||
|
--edge-hi: rgb(10 14 25 / 0.16);
|
||||||
|
--sheet: rgb(255 255 255 / 0.95);
|
||||||
|
--header: rgb(244 245 248 / 0.72);
|
||||||
|
--scrim: rgb(255 255 255 / 0.78);
|
||||||
|
--scrim-2: rgb(255 255 255 / 0.5);
|
||||||
|
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility glass {
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
backdrop-filter: var(--blur);
|
||||||
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility glass-hi {
|
||||||
|
background: var(--glass-hi);
|
||||||
|
border: 1px solid var(--edge-hi);
|
||||||
|
backdrop-filter: var(--blur);
|
||||||
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility sheet {
|
||||||
|
background: var(--sheet);
|
||||||
|
border: 1px solid var(--edge-hi);
|
||||||
|
backdrop-filter: var(--blur);
|
||||||
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility clamp1 {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very light noise over the gradients — large flat blurs band visibly on
|
||||||
|
8-bit panels without it. */
|
||||||
|
@utility grain {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyframes live here rather than in a <style> tag inside a component: the
|
||||||
|
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||||
|
elements are blocked outright. Setting element.style from JS is fine —
|
||||||
|
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: -220% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
100deg,
|
||||||
|
var(--glass) 30%,
|
||||||
|
var(--glass-hi) 50%,
|
||||||
|
var(--glass) 70%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "muse",
|
||||||
|
description: "Request a song, get it in your library, correctly tagged.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#08090d",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
||||||
|
* but it makes the Docker build reach out to Google at build time, and
|
||||||
|
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||||
|
* which is the look being aimed at anyway.
|
||||||
|
*/
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full antialiased">
|
||||||
|
<body className="flex min-h-full flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { api, type Me, type Track } from "@/lib/api";
|
||||||
|
import { tint } from "@/lib/colour";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { Aurora } from "@/components/aurora";
|
||||||
|
import { SearchBar } from "@/components/search-bar";
|
||||||
|
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 { Requests, type JobsSnapshot } from "@/components/requests";
|
||||||
|
import { Skeletons } from "@/components/skeletons";
|
||||||
|
import { PlayerProvider } from "@/components/player";
|
||||||
|
import { ToastProvider } from "@/components/toast";
|
||||||
|
|
||||||
|
type Tab = "search" | "jobs" | "bulk";
|
||||||
|
type View =
|
||||||
|
| { k: "welcome" }
|
||||||
|
| { k: "results"; q: string }
|
||||||
|
| { k: "album"; id: number }
|
||||||
|
| { k: "artist"; id: number };
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
"Radiohead Creep",
|
||||||
|
"Kendrick Lamar Money Trees",
|
||||||
|
"Fleetwood Mac Dreams",
|
||||||
|
"Tame Impala The Less I Know The Better",
|
||||||
|
"Frank Ocean Ivy",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<PlayerProvider>
|
||||||
|
<Muse />
|
||||||
|
</PlayerProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Muse() {
|
||||||
|
const [tab, setTab] = React.useState<Tab>("search");
|
||||||
|
const [view, setView] = React.useState<View>({ k: "welcome" });
|
||||||
|
const [history, setHistory] = React.useState<View[]>([]);
|
||||||
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [me, setMe] = React.useState<Me | null>(null);
|
||||||
|
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
||||||
|
|
||||||
|
const go = React.useCallback((next: View) => {
|
||||||
|
setView((cur) => {
|
||||||
|
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTab("search");
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const back = () => {
|
||||||
|
setHistory((h) => {
|
||||||
|
const prev = h[h.length - 1];
|
||||||
|
if (prev) setView(prev);
|
||||||
|
return h.slice(0, -1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSearch = React.useCallback(
|
||||||
|
async (q: string) => {
|
||||||
|
if (!q.trim()) return;
|
||||||
|
go({ k: "results", q });
|
||||||
|
setResults(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await api.search(q);
|
||||||
|
setResults(r.results);
|
||||||
|
if (r.results[0]) void tint(r.results[0].cover);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
},
|
||||||
|
[go],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
||||||
|
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSnap(await api.jobs());
|
||||||
|
} catch {
|
||||||
|
/* a 401 here just means the poll outlived the session */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
api.me().then(setMe).catch(() => undefined);
|
||||||
|
void refresh();
|
||||||
|
const t = setInterval(refresh, 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const active =
|
||||||
|
(snap?.counts.queued ?? 0) +
|
||||||
|
(snap?.counts.searching ?? 0) +
|
||||||
|
(snap?.counts.downloading ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Aurora />
|
||||||
|
|
||||||
|
<header
|
||||||
|
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
||||||
|
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-[940px] px-4">
|
||||||
|
<div className="mb-3.5 flex items-center gap-3">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ rotate: 12, scale: 1.06 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||||
|
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
||||||
|
border border-accent-line bg-accent-soft text-accent"
|
||||||
|
>
|
||||||
|
<Music2 className="h-5 w-5" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-[19px] font-bold leading-tight tracking-[-0.4px]">muse</h1>
|
||||||
|
<p className="text-xs text-dim">search · request · it appears in Navidrome</p>
|
||||||
|
</div>
|
||||||
|
{me?.user && (
|
||||||
|
<div className="flex items-center gap-2 text-[12.5px] text-dim">
|
||||||
|
{me.avatar && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={me.avatar}
|
||||||
|
alt=""
|
||||||
|
className="h-6 w-6 rounded-full border border-edge"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="hidden font-medium text-ink sm:inline">
|
||||||
|
{me.display || me.user}
|
||||||
|
</span>
|
||||||
|
{me.role && me.role !== "user" && (
|
||||||
|
<span
|
||||||
|
className="rounded-full border border-accent-line bg-accent-soft px-1.5
|
||||||
|
py-0.5 text-[10.5px] uppercase tracking-[0.6px] text-accent"
|
||||||
|
>
|
||||||
|
{me.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/auth/logout"
|
||||||
|
className="border-b border-transparent hover:border-edge-hi hover:text-ink"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||||
|
|
||||||
|
<nav className="flex pt-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["search", "Search"],
|
||||||
|
["jobs", "Requests"],
|
||||||
|
["bulk", "Import list"],
|
||||||
|
] as [Tab, string][]
|
||||||
|
).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setTab(k)}
|
||||||
|
className={cn(
|
||||||
|
"relative mr-4 cursor-pointer px-1 pb-3 pt-2 text-[13.5px] font-medium transition-colors",
|
||||||
|
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{k === "jobs" && active > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-1.5 inline-block min-w-[18px] rounded-full border
|
||||||
|
border-accent-line bg-accent-soft px-1.5 text-[11px]
|
||||||
|
font-bold text-accent"
|
||||||
|
>
|
||||||
|
{active}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === k && (
|
||||||
|
<motion.i
|
||||||
|
layoutId="tab-underline"
|
||||||
|
className="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-accent"
|
||||||
|
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{tab === "search" && (
|
||||||
|
<motion.div
|
||||||
|
key="search"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={back}
|
||||||
|
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
||||||
|
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||||
|
|
||||||
|
{view.k === "results" &&
|
||||||
|
(results === null ? (
|
||||||
|
<Skeletons />
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-dim">
|
||||||
|
No matches. Try adding the artist name.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div layout className="flex flex-col gap-[7px]">
|
||||||
|
{results.map((t, i) => (
|
||||||
|
<TrackRow
|
||||||
|
key={t.deezer_id}
|
||||||
|
track={t}
|
||||||
|
index={i}
|
||||||
|
onAlbum={openAlbum}
|
||||||
|
onArtist={openArtist}
|
||||||
|
onQueued={refresh}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{view.k === "album" && (
|
||||||
|
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||||
|
)}
|
||||||
|
{view.k === "artist" && (
|
||||||
|
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "jobs" && (
|
||||||
|
<motion.div
|
||||||
|
key="jobs"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
>
|
||||||
|
<Requests snap={snap} refresh={refresh} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "bulk" && (
|
||||||
|
<motion.div
|
||||||
|
key="bulk"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
>
|
||||||
|
<BulkImport onQueued={refresh} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
||||||
|
className="px-5 py-14 text-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, -7, 0] }}
|
||||||
|
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
||||||
|
border border-accent-line bg-accent-soft text-accent"
|
||||||
|
>
|
||||||
|
<Disc3 className="h-7 w-7" />
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="mb-2 text-[23px] font-bold tracking-[-0.4px]">Find anything</h2>
|
||||||
|
<p className="mx-auto max-w-[46ch] text-sm text-dim">
|
||||||
|
Search a song and press{" "}
|
||||||
|
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-[11.5px] text-ink">
|
||||||
|
Enter
|
||||||
|
</kbd>
|
||||||
|
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
||||||
|
your library, tagged.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||||
|
{EXAMPLES.map((e, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={e}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 + i * 0.05 }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
onClick={() => onPick(e)}
|
||||||
|
className="glass cursor-pointer rounded-full px-3.5 py-1.5 text-[13px] text-dim
|
||||||
|
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||||
|
import { tint } from "@/lib/colour";
|
||||||
|
import { Hero } from "@/components/hero";
|
||||||
|
import { TrackRow } from "@/components/track-row";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
import { Skeletons } from "@/components/skeletons";
|
||||||
|
|
||||||
|
export function AlbumView({
|
||||||
|
id,
|
||||||
|
onArtist,
|
||||||
|
onQueued,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
onArtist: (id: number) => void;
|
||||||
|
onQueued?: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [data, setData] = React.useState<{ album: AlbumHeader; tracks: Track[] } | null>(null);
|
||||||
|
const [err, setErr] = React.useState<string | null>(null);
|
||||||
|
const [batch, setBatch] = React.useState<"idle" | "busy" | "done">("idle");
|
||||||
|
const [queued, setQueued] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setData(null);
|
||||||
|
setErr(null);
|
||||||
|
setBatch("idle");
|
||||||
|
api
|
||||||
|
.album(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (!live) return;
|
||||||
|
setData(d);
|
||||||
|
void tint(d.album.cover_big ?? d.album.cover);
|
||||||
|
})
|
||||||
|
.catch((e) => live && setErr((e as Error).message));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
||||||
|
if (!data) return <Skeletons n={8} />;
|
||||||
|
|
||||||
|
const { album, tracks } = data;
|
||||||
|
|
||||||
|
const getAll = async () => {
|
||||||
|
setBatch("busy");
|
||||||
|
try {
|
||||||
|
const d = await api.requestBatch(tracks);
|
||||||
|
setQueued(d.queued);
|
||||||
|
setBatch("done");
|
||||||
|
toast(`Queued ${d.queued} tracks from ${album.title}`);
|
||||||
|
onQueued?.();
|
||||||
|
} catch (e) {
|
||||||
|
setBatch("idle");
|
||||||
|
toast(`Could not queue album: ${(e as Error).message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero
|
||||||
|
kind={album.record_type ?? "Album"}
|
||||||
|
title={album.title}
|
||||||
|
art={album.cover_big ?? album.cover}
|
||||||
|
by={
|
||||||
|
album.artist_id ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onArtist(album.artist_id!)}
|
||||||
|
className="border-b border-edge-hi hover:text-accent hover:border-accent-line cursor-pointer"
|
||||||
|
>
|
||||||
|
{album.artist}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
album.artist
|
||||||
|
)
|
||||||
|
}
|
||||||
|
facts={[
|
||||||
|
album.year,
|
||||||
|
album.genres?.[0],
|
||||||
|
album.label,
|
||||||
|
album.nb_tracks ? `${album.nb_tracks} tracks` : null,
|
||||||
|
album.duration ? mmss(album.duration * 1000) : null,
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||||
|
{batch === "idle"
|
||||||
|
? `Get all ${tracks.length} tracks`
|
||||||
|
: batch === "busy"
|
||||||
|
? "Queueing…"
|
||||||
|
: `Queued ${queued}`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div layout className="flex flex-col gap-[7px]">
|
||||||
|
{tracks.map((t, i) => (
|
||||||
|
<TrackRow
|
||||||
|
key={t.deezer_id}
|
||||||
|
track={t}
|
||||||
|
index={i}
|
||||||
|
showNumber
|
||||||
|
showArtist={false}
|
||||||
|
showAlbum={false}
|
||||||
|
onQueued={onQueued}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
|
||||||
|
import { tint } from "@/lib/colour";
|
||||||
|
import { Hero } from "@/components/hero";
|
||||||
|
import { TrackRow } from "@/components/track-row";
|
||||||
|
import { Skeletons } from "@/components/skeletons";
|
||||||
|
|
||||||
|
export function ArtistView({
|
||||||
|
id,
|
||||||
|
onAlbum,
|
||||||
|
onQueued,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
onAlbum: (id: number) => void;
|
||||||
|
onQueued?: () => void;
|
||||||
|
}) {
|
||||||
|
const [data, setData] = React.useState<{
|
||||||
|
artist: ArtistHeader;
|
||||||
|
tracks: Track[];
|
||||||
|
albums: AlbumCard[];
|
||||||
|
} | null>(null);
|
||||||
|
const [err, setErr] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setData(null);
|
||||||
|
setErr(null);
|
||||||
|
api
|
||||||
|
.artist(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (!live) return;
|
||||||
|
setData(d);
|
||||||
|
void tint(d.artist.picture);
|
||||||
|
})
|
||||||
|
.catch((e) => live && setErr((e as Error).message));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||||
|
if (!data) return <Skeletons n={8} />;
|
||||||
|
|
||||||
|
const { artist, tracks, albums } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero
|
||||||
|
kind="Artist"
|
||||||
|
title={artist.name}
|
||||||
|
art={artist.picture}
|
||||||
|
round
|
||||||
|
facts={[
|
||||||
|
artist.nb_fan ? `${artist.nb_fan.toLocaleString()} fans` : null,
|
||||||
|
artist.nb_album ? `${artist.nb_album} releases` : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionHead>Top tracks</SectionHead>
|
||||||
|
<motion.div layout className="flex flex-col gap-[7px]">
|
||||||
|
{tracks.map((t, i) => (
|
||||||
|
<TrackRow
|
||||||
|
key={t.deezer_id}
|
||||||
|
track={t}
|
||||||
|
index={i}
|
||||||
|
showArtist={false}
|
||||||
|
onAlbum={onAlbum}
|
||||||
|
onQueued={onQueued}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHead>Releases</SectionHead>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(136px,1fr))] gap-3.5">
|
||||||
|
{albums.map((a, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={a.album_id}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(i * 0.025, 0.5) }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
onClick={() => onAlbum(a.album_id)}
|
||||||
|
className="cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={a.cover ?? ""}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="mb-2 aspect-square w-full rounded-[13px] bg-faint object-cover
|
||||||
|
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
|
||||||
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||||
|
/>
|
||||||
|
<div className="text-[13.5px] font-medium clamp1">{a.title}</div>
|
||||||
|
<div className="text-xs text-dim clamp1">
|
||||||
|
{[a.year, a.record_type].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h3 className="mt-6 mb-3 text-xs font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
|
||||||
|
* the artwork sets, so the page takes on the palette of whatever is on screen.
|
||||||
|
*
|
||||||
|
* Kept faint on purpose: at full strength the gradient competes with the album
|
||||||
|
* art it is derived from, and the glass rows lose contrast against it.
|
||||||
|
*/
|
||||||
|
export function Aurora() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
|
||||||
|
<motion.i
|
||||||
|
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
|
||||||
|
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
|
||||||
|
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
|
||||||
|
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
<motion.i
|
||||||
|
className="absolute block rounded-full opacity-30 blur-[110px]
|
||||||
|
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
|
||||||
|
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
|
||||||
|
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
|
||||||
|
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
{/* Channel-rotated so the palette has range rather than one flat wash,
|
||||||
|
but faint — a full-strength complement fights the artwork. */}
|
||||||
|
<motion.i
|
||||||
|
className="absolute block rounded-full opacity-[0.14] blur-[110px]
|
||||||
|
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
|
||||||
|
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
|
||||||
|
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Result = Awaited<ReturnType<typeof api.importList>>;
|
||||||
|
|
||||||
|
const PLACEHOLDER = `Radiohead - Creep
|
||||||
|
Juice WRLD – Lucid Dreams
|
||||||
|
3. Gorillaz - Feel Good Inc
|
||||||
|
Black Hole Sun by Soundgarden
|
||||||
|
https://deezer.com/album/14880711`;
|
||||||
|
|
||||||
|
export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [text, setText] = React.useState("");
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [out, setOut] = React.useState<Result | null>(null);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) {
|
||||||
|
toast("Paste some songs first", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setOut(null);
|
||||||
|
try {
|
||||||
|
const d = await api.importList(t);
|
||||||
|
setOut(d);
|
||||||
|
if (d.queued.length) {
|
||||||
|
setText("");
|
||||||
|
toast(`Queued ${d.queued.length} tracks`);
|
||||||
|
onQueued?.();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(`Import failed: ${(e as Error).message}`, true);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="glass rounded-panel p-5"
|
||||||
|
>
|
||||||
|
<h2 className="mb-1 text-[17px] font-bold tracking-[-0.3px]">Import a list</h2>
|
||||||
|
<p className="mb-3.5 max-w-[62ch] text-[13px] text-dim">
|
||||||
|
One song per line. Deezer album and playlist URLs are expanded into their tracks. The
|
||||||
|
top match is taken automatically — every job shows what it resolved to, so a bad pick is
|
||||||
|
visible rather than silent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={PLACEHOLDER}
|
||||||
|
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||||||
|
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||||
|
text-ink outline-none transition
|
||||||
|
placeholder:text-dim/65
|
||||||
|
focus:border-accent-line focus:shadow-[0_0_0_4px_var(--color-accent-soft)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={run} disabled={busy}>
|
||||||
|
{busy ? "Queueing…" : "Queue all"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[12.5px] text-dim">
|
||||||
|
Top match taken automatically — check what each line resolved to.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{out && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-3.5 overflow-hidden"
|
||||||
|
>
|
||||||
|
{out.queued.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-1 text-[12.5px] text-dim">Queued {out.queued.length}:</div>
|
||||||
|
{out.queued.map((q, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={q.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: Math.min(i * 0.03, 0.5) }}
|
||||||
|
className="flex items-center gap-2.5 border-b border-edge py-1.5 text-[13px] last:border-0"
|
||||||
|
>
|
||||||
|
{q.cover && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={q.cover}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-[30px] w-[30px] shrink-0 rounded-md bg-faint object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 text-dim">{q.line}</span>
|
||||||
|
<ArrowRight className="h-3 w-3 shrink-0 text-dim opacity-50" />
|
||||||
|
<span className="clamp1">{q.matched}</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{out.skipped.length > 0 && (
|
||||||
|
<div className="mt-2.5 text-[12.5px] leading-[1.7] text-err">
|
||||||
|
Skipped {out.skipped.length}:{" "}
|
||||||
|
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album/artist header. The artwork itself, blown up and blurred, is the
|
||||||
|
* panel's own backdrop — cheaper and more faithful than trying to synthesise
|
||||||
|
* a gradient from the extracted accent, and it reads as the record rather
|
||||||
|
* than as a themed box.
|
||||||
|
*/
|
||||||
|
export function Hero({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
art,
|
||||||
|
round,
|
||||||
|
by,
|
||||||
|
facts,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
art: string | null;
|
||||||
|
round?: boolean;
|
||||||
|
by?: React.ReactNode;
|
||||||
|
facts?: (string | null | undefined)[];
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||||
|
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||||
|
>
|
||||||
|
{art && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={art}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 z-0 h-full w-full scale-[1.35] object-cover
|
||||||
|
opacity-40 blur-[46px] saturate-[1.8]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 z-[1] bg-gradient-to-r from-[var(--scrim)] to-[var(--scrim-2)]" />
|
||||||
|
|
||||||
|
{art && (
|
||||||
|
<motion.img
|
||||||
|
layoutId={`art-${title}`}
|
||||||
|
src={art}
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
"relative z-[2] w-[132px] h-[132px] sm:w-[150px] sm:h-[150px] shrink-0 object-cover",
|
||||||
|
"bg-faint shadow-[0_14px_40px_rgb(0_0_0/0.5)]",
|
||||||
|
round ? "rounded-full" : "rounded-2xl",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center">
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-[1px] text-accent">
|
||||||
|
{kind}
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{by && <div className="mb-2.5 text-[15px] font-semibold">{by}</div>}
|
||||||
|
{facts && (
|
||||||
|
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
|
||||||
|
{facts.filter(Boolean).map((f, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="glass-hi rounded-full px-2.5 py-0.5 text-[11.5px] text-dim"
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actions && (
|
||||||
|
<div className="flex flex-wrap justify-center sm:justify-start gap-2.5">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Square } from "lucide-react";
|
||||||
|
import { mmss, type Track } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 30-second Deezer preview.
|
||||||
|
*
|
||||||
|
* Deliberately NOT a library player. Playing your own music belongs in
|
||||||
|
* Navidrome, which already does offline, CarPlay, gapless and scrobbling, and
|
||||||
|
* has a dozen native clients. This exists for one job: confirm you picked the
|
||||||
|
* right recording before a Soulseek search is committed to it — which is the
|
||||||
|
* cheapest possible guard against importing the wrong take.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
playing: number | null;
|
||||||
|
toggle: (t: Track) => void;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayerCtx = React.createContext<Ctx>({
|
||||||
|
playing: null,
|
||||||
|
toggle: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePlayer = () => React.useContext(PlayerCtx);
|
||||||
|
|
||||||
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [track, setTrack] = React.useState<Track | null>(null);
|
||||||
|
const [pct, setPct] = React.useState(0);
|
||||||
|
const [left, setLeft] = React.useState(30_000);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && !audioRef.current) {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.preload = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = React.useCallback(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (a) {
|
||||||
|
a.pause();
|
||||||
|
a.removeAttribute("src");
|
||||||
|
a.load();
|
||||||
|
}
|
||||||
|
setTrack(null);
|
||||||
|
setPct(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = React.useCallback(
|
||||||
|
(t: Track) => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a || !t.preview) return;
|
||||||
|
if (track?.deezer_id === t.deezer_id) {
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
a.pause();
|
||||||
|
a.src = t.preview;
|
||||||
|
void a.play().catch(() => undefined);
|
||||||
|
setTrack(t);
|
||||||
|
setPct(0);
|
||||||
|
},
|
||||||
|
[track, stop],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const a = audioRef.current;
|
||||||
|
if (!a) return;
|
||||||
|
const onTime = () => {
|
||||||
|
const d = a.duration || 30;
|
||||||
|
setPct((a.currentTime / d) * 100);
|
||||||
|
setLeft((d - a.currentTime) * 1000);
|
||||||
|
};
|
||||||
|
a.addEventListener("timeupdate", onTime);
|
||||||
|
a.addEventListener("ended", stop);
|
||||||
|
return () => {
|
||||||
|
a.removeEventListener("timeupdate", onTime);
|
||||||
|
a.removeEventListener("ended", stop);
|
||||||
|
};
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Stop playback when the tab is hidden — nobody wants a phantom 30s clip.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onHide = () => document.hidden && stop();
|
||||||
|
document.addEventListener("visibilitychange", onHide);
|
||||||
|
return () => document.removeEventListener("visibilitychange", onHide);
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({ playing: track?.deezer_id ?? null, toggle, stop }),
|
||||||
|
[track, toggle, stop],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerCtx.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<AnimatePresence>
|
||||||
|
{track && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 22, x: "-50%" }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||||
|
exit={{ opacity: 0, y: 22, x: "-50%" }}
|
||||||
|
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||||
|
className="sheet fixed bottom-4 left-1/2 z-70 flex items-center gap-3
|
||||||
|
rounded-[20px] py-2 pl-2 pr-4 w-[min(560px,calc(100vw-28px))]"
|
||||||
|
>
|
||||||
|
{track.cover && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={track.cover}
|
||||||
|
alt=""
|
||||||
|
className="w-[42px] h-[42px] rounded-[9px] object-cover bg-faint shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13.5px] font-semibold clamp1">{track.title}</div>
|
||||||
|
<div className="text-xs text-dim clamp1">{track.artist}</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0">
|
||||||
|
<motion.i
|
||||||
|
className="block h-full rounded-full bg-accent"
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ ease: "linear", duration: 0.25 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:block text-[11.5px] text-dim tabular-nums shrink-0">
|
||||||
|
{mmss(left)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={stop}
|
||||||
|
aria-label="Stop preview"
|
||||||
|
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||||
|
text-ink cursor-pointer transition hover:border-accent-line
|
||||||
|
hover:text-accent"
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3 fill-current" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</PlayerCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { api, type Job } from "@/lib/api";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type JobsSnapshot = {
|
||||||
|
jobs: Job[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PILL: Record<Job["state"], string> = {
|
||||||
|
imported: "text-ok border-ok/40 bg-ok/10",
|
||||||
|
failed: "text-err border-err/40 bg-err/10",
|
||||||
|
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||||
|
searching: "text-warn border-warn/40 bg-warn/10",
|
||||||
|
queued: "text-warn border-warn/40 bg-warn/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||||
|
const toast = useToast();
|
||||||
|
if (!snap) return null;
|
||||||
|
|
||||||
|
const { jobs, counts, pending } = snap;
|
||||||
|
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||||
|
const done = counts.imported ?? 0;
|
||||||
|
const failed = counts.failed ?? 0;
|
||||||
|
const total = active + done;
|
||||||
|
|
||||||
|
const stats: [string, number, string][] = [
|
||||||
|
["In flight", active, "text-accent"],
|
||||||
|
["Imported", done, "text-ok"],
|
||||||
|
["Failed", failed, "text-err"],
|
||||||
|
["Waiting", pending, ""],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||||
|
{stats.map(([label, n, colour]) => (
|
||||||
|
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||||
|
<motion.b
|
||||||
|
key={n}
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={cn("block text-2xl font-bold leading-tight tracking-[-0.7px]", colour)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</motion.b>
|
||||||
|
<span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{active > 0 && total > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 4 }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||||
|
>
|
||||||
|
<motion.i
|
||||||
|
className="block h-1 rounded-full bg-accent"
|
||||||
|
animate={{ width: `${(done / total) * 100}%` }}
|
||||||
|
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||||
|
) : (
|
||||||
|
<motion.div layout className="flex flex-col gap-[7px]">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<motion.div
|
||||||
|
key={j.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.97 }}
|
||||||
|
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||||
|
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||||
|
>
|
||||||
|
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||||
|
{j.cover && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={j.cover}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[14.5px] font-semibold clamp1">{j.title}</div>
|
||||||
|
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||||
|
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{j.state === "failed" && (
|
||||||
|
<Button
|
||||||
|
variant="quiet"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.retry(j.id);
|
||||||
|
toast("Requeued");
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
||||||
|
"text-[11px] font-semibold capitalize",
|
||||||
|
PILL[j.state] ?? "text-dim border-edge",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(j.state === "searching" || j.state === "downloading") && (
|
||||||
|
<motion.span
|
||||||
|
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||||
|
animate={{ opacity: [1, 0.25, 1] }}
|
||||||
|
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{j.state}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Search, X, Loader2 } from "lucide-react";
|
||||||
|
import { api, type Suggestions } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
busy,
|
||||||
|
onSearch,
|
||||||
|
onAlbum,
|
||||||
|
onArtist,
|
||||||
|
}: {
|
||||||
|
busy?: boolean;
|
||||||
|
onSearch: (q: string) => void;
|
||||||
|
onAlbum: (id: number) => void;
|
||||||
|
onArtist: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = React.useState("");
|
||||||
|
const [items, setItems] = React.useState<Item[]>([]);
|
||||||
|
const [open, setOpen] = React.useState(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
|
||||||
|
// to "rad" can land after "radiohead" and repopulate the stale list.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const v = q.trim();
|
||||||
|
if (v.length < 2) {
|
||||||
|
setItems([]);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const d: Suggestions = await api.suggest(v, ac.signal);
|
||||||
|
const next: Item[] = [
|
||||||
|
...d.tracks.map((t) => ({
|
||||||
|
kind: "track" as const,
|
||||||
|
label: t.title,
|
||||||
|
sub: t.artist,
|
||||||
|
cover: t.cover,
|
||||||
|
run: () => {
|
||||||
|
const nq = `${t.artist} ${t.title}`;
|
||||||
|
setQ(nq);
|
||||||
|
setOpen(false);
|
||||||
|
onSearch(nq);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...d.albums.map((a) => ({
|
||||||
|
kind: "album" as const,
|
||||||
|
label: a.title,
|
||||||
|
sub: a.artist,
|
||||||
|
cover: a.cover,
|
||||||
|
meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "",
|
||||||
|
run: () => {
|
||||||
|
setOpen(false);
|
||||||
|
onAlbum(a.album_id);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...d.artists.map((a) => ({
|
||||||
|
kind: "artist" as const,
|
||||||
|
label: a.name,
|
||||||
|
sub: a.nb_album ? `${a.nb_album} releases` : "Artist",
|
||||||
|
cover: a.cover,
|
||||||
|
run: () => {
|
||||||
|
setOpen(false);
|
||||||
|
onArtist(a.artist_id);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
setItems(next);
|
||||||
|
setSel(-1);
|
||||||
|
setOpen(next.length > 0);
|
||||||
|
} catch {
|
||||||
|
/* aborted or offline — leave the previous list alone */
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
ac.abort();
|
||||||
|
};
|
||||||
|
}, [q, onSearch, onAlbum, onArtist]);
|
||||||
|
|
||||||
|
// "/" focuses search, the way every music app does.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const tag = (document.activeElement as HTMLElement)?.tagName;
|
||||||
|
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const key = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSel((s) => (items.length ? (s + 1) % items.length : -1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1));
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (sel >= 0 && items[sel]) items[sel].run();
|
||||||
|
else {
|
||||||
|
setOpen(false);
|
||||||
|
onSearch(q.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: { title: string; kind: Item["kind"] }[] = [
|
||||||
|
{ title: "Songs", kind: "track" },
|
||||||
|
{ title: "Albums", kind: "album" },
|
||||||
|
{ title: "Artists", kind: "artist" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-50">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||||
|
"transition-all duration-250 ease-glass",
|
||||||
|
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
||||||
|
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="w-[18px] h-[18px] shrink-0 text-dim" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={key}
|
||||||
|
onFocus={() => items.length && setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||||
|
placeholder="Search songs, albums, artists…"
|
||||||
|
aria-label="Search"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base
|
||||||
|
font-medium tracking-[-0.1px] text-ink placeholder:text-dim
|
||||||
|
placeholder:font-normal"
|
||||||
|
/>
|
||||||
|
{busy && <Loader2 className="w-4 h-4 shrink-0 text-accent animate-spin" />}
|
||||||
|
{q && !busy && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
||||||
|
glass-hi text-dim hover:text-ink cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && items.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8, scale: 0.985 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.985 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.22, 0.9, 0.28, 1] }}
|
||||||
|
className="sheet absolute top-[calc(100%+8px)] left-0 right-0 z-60 p-1.5
|
||||||
|
rounded-[20px] max-h-[min(64vh,460px)] overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
{groups.map((g) => {
|
||||||
|
const rows = items.filter((i) => i.kind === g.kind);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={g.kind}>
|
||||||
|
<div className="px-2.5 pt-2 pb-1 text-[10.5px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
|
{g.title}
|
||||||
|
</div>
|
||||||
|
{rows.map((it) => {
|
||||||
|
const idx = items.indexOf(it);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${g.kind}-${idx}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={idx === sel}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
it.run();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSel(idx)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors",
|
||||||
|
idx === sel && "bg-accent-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={it.cover ?? ""}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className={cn(
|
||||||
|
"w-[38px] h-[38px] shrink-0 object-cover bg-faint",
|
||||||
|
it.kind === "artist" ? "rounded-full" : "rounded-lg",
|
||||||
|
)}
|
||||||
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium clamp1">{it.label}</div>
|
||||||
|
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
||||||
|
</div>
|
||||||
|
{it.meta && <div className="shrink-0 text-[11px] text-dim">{it.meta}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
|
||||||
|
export function Skeletons({ n = 6 }: { n?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[7px]">
|
||||||
|
{Array.from({ length: n }).map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
className="shimmer h-[68px] rounded-glass border border-edge"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Toast = { id: number; msg: string; err?: boolean };
|
||||||
|
type Ctx = (msg: string, err?: boolean) => void;
|
||||||
|
|
||||||
|
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||||
|
export const useToast = () => React.useContext(ToastCtx);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [items, setItems] = React.useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const push = React.useCallback<Ctx>((msg, err) => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setItems((x) => [...x, { id, msg, err }]);
|
||||||
|
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastCtx.Provider value={push}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
|
||||||
|
<AnimatePresence>
|
||||||
|
{items.map((t) => (
|
||||||
|
<motion.div
|
||||||
|
key={t.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||||
|
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||||
|
className={cn(
|
||||||
|
"sheet rounded-full px-5 py-2.5 text-[13.5px] font-medium text-center",
|
||||||
|
"max-w-[calc(100vw-32px)]",
|
||||||
|
t.err && "border-err/50 text-err",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.msg}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Play, Pause } from "lucide-react";
|
||||||
|
import { api, mmss, type Track } from "@/lib/api";
|
||||||
|
import { usePlayer } from "@/components/player";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function TrackRow({
|
||||||
|
track,
|
||||||
|
index,
|
||||||
|
showNumber,
|
||||||
|
showArtist = true,
|
||||||
|
showAlbum = true,
|
||||||
|
onAlbum,
|
||||||
|
onArtist,
|
||||||
|
onQueued,
|
||||||
|
}: {
|
||||||
|
track: Track;
|
||||||
|
index: number;
|
||||||
|
showNumber?: boolean;
|
||||||
|
showArtist?: boolean;
|
||||||
|
showAlbum?: boolean;
|
||||||
|
onAlbum?: (id: number) => void;
|
||||||
|
onArtist?: (id: number) => void;
|
||||||
|
onQueued?: () => void;
|
||||||
|
}) {
|
||||||
|
const { playing, toggle } = usePlayer();
|
||||||
|
const toast = useToast();
|
||||||
|
const [state, setState] = React.useState<"idle" | "busy" | "done">("idle");
|
||||||
|
|
||||||
|
const isPlaying = playing === track.deezer_id;
|
||||||
|
|
||||||
|
const queue = async () => {
|
||||||
|
setState("busy");
|
||||||
|
try {
|
||||||
|
await api.request(track);
|
||||||
|
setState("done");
|
||||||
|
toast(`Queued ${track.artist} — ${track.title}`);
|
||||||
|
onQueued?.();
|
||||||
|
} catch (e) {
|
||||||
|
setState("idle");
|
||||||
|
toast(`Could not queue: ${(e as Error).message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const facts: React.ReactNode[] = [];
|
||||||
|
if (showAlbum && track.album)
|
||||||
|
facts.push(
|
||||||
|
track.album_id && onAlbum ? (
|
||||||
|
<button
|
||||||
|
key="al"
|
||||||
|
onClick={() => onAlbum(track.album_id!)}
|
||||||
|
className="hover:text-accent hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{track.album}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span key="al">{track.album}</span>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (track.year) facts.push(<span key="yr">{track.year}</span>);
|
||||||
|
if (track.genre) facts.push(<span key="gn">{track.genre}</span>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||||
|
whileHover={{ y: -1 }}
|
||||||
|
className={cn(
|
||||||
|
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||||
|
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
||||||
|
isPlaying && "border-accent-line bg-accent-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showNumber && (
|
||||||
|
<div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums">
|
||||||
|
{track.track ?? ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(track)}
|
||||||
|
disabled={!track.preview}
|
||||||
|
aria-label={track.preview ? `Preview ${track.title}` : "No preview available"}
|
||||||
|
className={cn(
|
||||||
|
"relative w-[50px] h-[50px] shrink-0 rounded-[9px] overflow-hidden bg-faint group",
|
||||||
|
track.preview ? "cursor-pointer" : "cursor-default",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{track.cover && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={track.cover}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{track.preview && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 grid place-items-center bg-black/50 text-white",
|
||||||
|
"transition-opacity duration-200",
|
||||||
|
isPlaying ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="w-[18px] h-[18px] fill-current" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-[18px] h-[18px] fill-current" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1">
|
||||||
|
{track.title}
|
||||||
|
{track.explicit && (
|
||||||
|
<span className="ml-1.5 align-[1px] inline-block rounded-[3px] border border-edge bg-faint px-1 text-[9px] font-bold text-dim">
|
||||||
|
E
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||||
|
{showArtist && track.artist_id && onArtist ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onArtist(track.artist_id!)}
|
||||||
|
className="hover:text-accent hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{track.artist}
|
||||||
|
</button>
|
||||||
|
) : showArtist ? (
|
||||||
|
<span>{track.artist}</span>
|
||||||
|
) : null}
|
||||||
|
{facts.map((f, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{(showArtist || i > 0) && <span className="mx-1.5 opacity-50">·</span>}
|
||||||
|
{f}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums">
|
||||||
|
{track.length ? mmss(track.length) : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="pill"
|
||||||
|
onClick={queue}
|
||||||
|
disabled={state !== "idle"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
state === "done" && "border-ok/50 text-ok bg-ok/10 hover:bg-ok/10 hover:text-ok hover:border-ok/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === "idle" ? "Get" : state === "busy" ? "…" : "Queued"}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const button = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
||||||
|
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
||||||
|
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
// Solid accent. The label is near-black because the accent is clamped
|
||||||
|
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
||||||
|
// to stay in step or this button becomes unreadable on some albums.
|
||||||
|
primary: "bg-accent text-[#08090d] hover:brightness-110 active:scale-[0.97]",
|
||||||
|
ghost:
|
||||||
|
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
||||||
|
outline:
|
||||||
|
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
||||||
|
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
||||||
|
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "text-xs px-3 py-1.5 rounded-full",
|
||||||
|
md: "text-sm px-4.5 py-2.5 rounded-xl",
|
||||||
|
pill: "text-[13px] px-4 py-2 rounded-full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "primary", size: "md" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof button> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
/** Types mirror what app.py's track_row() and endpoints actually return. */
|
||||||
|
|
||||||
|
export type Track = {
|
||||||
|
deezer_id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
artist_id: number | null;
|
||||||
|
artist_pic: string | null;
|
||||||
|
album: string;
|
||||||
|
album_id: number | null;
|
||||||
|
cover: string | null;
|
||||||
|
cover_big: string | null;
|
||||||
|
length: number;
|
||||||
|
preview: string | null;
|
||||||
|
explicit: boolean;
|
||||||
|
isrc: string | null;
|
||||||
|
rank: number;
|
||||||
|
year: string | null;
|
||||||
|
genre: string | null;
|
||||||
|
track: number | null;
|
||||||
|
disc?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlbumHeader = {
|
||||||
|
album_id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
artist_id: number | null;
|
||||||
|
cover: string | null;
|
||||||
|
cover_big: string | null;
|
||||||
|
year: string | null;
|
||||||
|
genres: string[];
|
||||||
|
label: string | null;
|
||||||
|
nb_tracks: number | null;
|
||||||
|
record_type: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtistHeader = {
|
||||||
|
artist_id: number;
|
||||||
|
name: string;
|
||||||
|
picture: string | null;
|
||||||
|
nb_fan: number | null;
|
||||||
|
nb_album: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlbumCard = {
|
||||||
|
album_id: number;
|
||||||
|
title: string;
|
||||||
|
cover: string | null;
|
||||||
|
cover_big: string | null;
|
||||||
|
year: string | null;
|
||||||
|
record_type: string | null;
|
||||||
|
nb_tracks: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Suggestions = {
|
||||||
|
tracks: {
|
||||||
|
deezer_id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
cover: string | null;
|
||||||
|
preview: string | null;
|
||||||
|
}[];
|
||||||
|
albums: {
|
||||||
|
album_id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
cover: string | null;
|
||||||
|
nb_tracks: number | null;
|
||||||
|
}[];
|
||||||
|
artists: {
|
||||||
|
artist_id: number;
|
||||||
|
name: string;
|
||||||
|
cover: string | null;
|
||||||
|
nb_album: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
id: string;
|
||||||
|
state: "queued" | "searching" | "downloading" | "imported" | "failed";
|
||||||
|
detail: string | null;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
album: string | null;
|
||||||
|
year: string | null;
|
||||||
|
cover: string | null;
|
||||||
|
genre: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
requested_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Me = {
|
||||||
|
user: string | null;
|
||||||
|
auth: string;
|
||||||
|
role?: string;
|
||||||
|
display?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
quota?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every response goes through here so a 401 is a typed failure rather than a
|
||||||
|
* JSON parse error thirty lines away. Sessions expire, and when one does the
|
||||||
|
* UI should say "signed out", not "Unexpected token < in JSON".
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(path, { credentials: "same-origin", ...init });
|
||||||
|
if (r.status === 401) throw new ApiError(401, "signed out");
|
||||||
|
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return get<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => get<Me>("/api/me"),
|
||||||
|
search: (q: string) =>
|
||||||
|
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
|
suggest: (q: string, signal?: AbortSignal) =>
|
||||||
|
get<Suggestions>(`/api/suggest?q=${encodeURIComponent(q)}`, { signal }),
|
||||||
|
album: (id: number | string) =>
|
||||||
|
get<{ album: AlbumHeader; tracks: Track[] }>(
|
||||||
|
`/api/album/${encodeURIComponent(String(id))}`,
|
||||||
|
),
|
||||||
|
artist: (id: number | string) =>
|
||||||
|
get<{ artist: ArtistHeader; tracks: Track[]; albums: AlbumCard[] }>(
|
||||||
|
`/api/artist/${encodeURIComponent(String(id))}`,
|
||||||
|
),
|
||||||
|
request: (t: Track) => post<{ id: string }>("/api/request", t),
|
||||||
|
requestBatch: (tracks: Track[]) =>
|
||||||
|
post<{ queued: number; ids: string[]; pending: number }>(
|
||||||
|
"/api/request/batch",
|
||||||
|
{ tracks },
|
||||||
|
),
|
||||||
|
jobs: () =>
|
||||||
|
get<{ jobs: Job[]; counts: Record<string, number>; pending: number }>(
|
||||||
|
"/api/jobs",
|
||||||
|
),
|
||||||
|
retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`),
|
||||||
|
importList: (text: string) =>
|
||||||
|
post<{
|
||||||
|
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
||||||
|
skipped: { line: string; why: string }[];
|
||||||
|
pending: number;
|
||||||
|
}>("/api/import", { text }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mmss = (ms: number | null | undefined) => {
|
||||||
|
const t = Math.round((ms || 0) / 1000);
|
||||||
|
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Dominant colour of an album cover, used as the page accent.
|
||||||
|
*
|
||||||
|
* Deezer's CDN sends `access-control-allow-origin: *`, so the artwork can be
|
||||||
|
* drawn into a canvas without tainting it and read back. That is the whole
|
||||||
|
* reason this works client-side with no image proxy and no server CPU — it
|
||||||
|
* was checked before the feature was designed around it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cache = new Map<string, [number, number, number]>();
|
||||||
|
|
||||||
|
export function dominant(url: string | null): Promise<[number, number, number] | null> {
|
||||||
|
if (!url) return Promise.resolve(null);
|
||||||
|
const hit = cache.get(url);
|
||||||
|
if (hit) return Promise.resolve(hit);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const n = 32;
|
||||||
|
const cv = document.createElement("canvas");
|
||||||
|
cv.width = cv.height = n;
|
||||||
|
const cx = cv.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!cx) return resolve(null);
|
||||||
|
cx.drawImage(img, 0, 0, n, n);
|
||||||
|
const d = cx.getImageData(0, 0, n, n).data;
|
||||||
|
|
||||||
|
// Coarse 4-bit-per-channel histogram, weighted by saturation. Most
|
||||||
|
// album art is dominated by greys and near-blacks in raw pixel count,
|
||||||
|
// which say nothing about its palette — the weighting is what keeps
|
||||||
|
// the accent off them.
|
||||||
|
const bins = new Map<number, { w: number; r: number; g: number; b: number; n: number }>();
|
||||||
|
for (let i = 0; i < d.length; i += 4) {
|
||||||
|
if (d[i + 3] < 128) continue;
|
||||||
|
const r = d[i], g = d[i + 1], b = d[i + 2];
|
||||||
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
||||||
|
if (mx < 32 || mx > 246) continue; // near-black / blown out
|
||||||
|
const sat = mx ? (mx - mn) / mx : 0;
|
||||||
|
const key = (r >> 4) * 256 + (g >> 4) * 16 + (b >> 4);
|
||||||
|
const cur = bins.get(key) ?? { w: 0, r: 0, g: 0, b: 0, n: 0 };
|
||||||
|
cur.w += 1 + sat * 5;
|
||||||
|
cur.r += r; cur.g += g; cur.b += b; cur.n++;
|
||||||
|
bins.set(key, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: { w: number; r: number; g: number; b: number; n: number } | null = null;
|
||||||
|
for (const v of bins.values()) if (!best || v.w > best.w) best = v;
|
||||||
|
if (!best) return resolve(null);
|
||||||
|
|
||||||
|
const [r0, g0, b0] = [best.r / best.n, best.g / best.n, best.b / best.n];
|
||||||
|
|
||||||
|
// Clamp luminance so the accent stays readable as text on the dark
|
||||||
|
// shell AND dark enough to sit behind the near-black label on solid
|
||||||
|
// accent buttons. Album art happily supplies both #0a0a0a and #fffef8.
|
||||||
|
const lum = (0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0) / 255;
|
||||||
|
const k = lum < 0.45 ? 0.45 / Math.max(lum, 0.04) : lum > 0.82 ? 0.82 / lum : 1;
|
||||||
|
const rgb = [r0, g0, b0].map((v) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(v * k))),
|
||||||
|
) as [number, number, number];
|
||||||
|
|
||||||
|
cache.set(url, rgb);
|
||||||
|
resolve(rgb);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(null);
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = 0;
|
||||||
|
|
||||||
|
/** Apply an artwork's colour as the live accent. Last call wins. */
|
||||||
|
export async function tint(url: string | null) {
|
||||||
|
const mine = ++token;
|
||||||
|
const rgb = await dominant(url);
|
||||||
|
if (!rgb || mine !== token) return; // a newer view won the race
|
||||||
|
const s = document.documentElement.style;
|
||||||
|
s.setProperty("--a-r", String(rgb[0]));
|
||||||
|
s.setProperty("--a-g", String(rgb[1]));
|
||||||
|
s.setProperty("--a-b", String(rgb[2]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/** shadcn's `cn` — merge conditional classes, last Tailwind utility wins. */
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static export, served by the FastAPI app.
|
||||||
|
*
|
||||||
|
* The alternative — running `next start` alongside uvicorn — means a Node
|
||||||
|
* runtime in the image, a second process to supervise and health-check, and a
|
||||||
|
* second thing that can be down while the other is up. muse has no use for
|
||||||
|
* SSR: every view is behind auth and driven by live JSON, so there is nothing
|
||||||
|
* to render on a server that the client would not immediately re-render.
|
||||||
|
*
|
||||||
|
* So `next build` emits plain files into out/, FastAPI serves them, and the
|
||||||
|
* deployment stays what it was: one container, one process, one healthcheck.
|
||||||
|
* The auth middleware still guards the whole surface, because it is the same
|
||||||
|
* server handing out the HTML.
|
||||||
|
*/
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const API = process.env.MUSE_API_ORIGIN ?? "http://127.0.0.1:5041";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
...(isProd ? { output: "export" as const } : {}),
|
||||||
|
|
||||||
|
// No Next image optimiser without a server. Artwork already arrives from
|
||||||
|
// Deezer's CDN at the size it is displayed, so there is nothing to optimise.
|
||||||
|
images: { unoptimized: true },
|
||||||
|
|
||||||
|
// Dev only: `next dev` on :3000 proxies the API to the FastAPI process so
|
||||||
|
// the browser sees a single origin and cookies behave as they do in prod.
|
||||||
|
...(isProd
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{ source: "/api/:path*", destination: `${API}/api/:path*` },
|
||||||
|
{ source: "/auth/:path*", destination: `${API}/auth/:path*` },
|
||||||
|
{ source: "/healthz", destination: `${API}/healthz` },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7843
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-progress": "^1.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.43.0",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
|
"next": "16.3.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user