commit 210ba5cbd5ee59aba77c2fa6a3ee74b25d67cb0e
parent 9dec174a2bf61d30704a926b51314913b42a49c6
Author: Pablo Murad <pblmrd@gmail.com>
Date: Wed, 20 May 2026 21:26:20 -0300
getting better
Diffstat:
37 files changed, 2722 insertions(+), 272 deletions(-)
diff --git a/.env.example b/.env.example
@@ -14,6 +14,21 @@
# Default in code: data/metadata.tsv inside this project (see data/metadata.tsv)
METADATA_TSV=data/metadata.tsv
+# SQLite index built from metadata.tsv (npm run index-metadata)
+# TRACKS_DB=data/tracks.db
+
+# Comma-separated origins for API CORS (production default: site URL). Use * to allow all.
+# CORS_ORIGINS=https://mymusics.murad.gg
+
+# Public site URL (oEmbed, server)
+# PUBLIC_SITE_URL=https://mymusics.murad.gg
+
+# Restrict embed postMessage target (optional; client VITE_EMBED_PARENT_ORIGIN)
+# VITE_EMBED_PARENT_ORIGIN=https://example.com
+
+# verify-tracks cron sample size
+# VERIFY_SAMPLE_SIZE=50
+
# Optional: Internet Archive item identifier (default: myspace_dragon_hoard_2010)
# IA_ITEM_ID=myspace_dragon_hoard_2010
diff --git a/.gitignore b/.gitignore
@@ -13,6 +13,10 @@ dist-server
dist-ssr
*.local
+data/tracks.db
+data/tracks.db-*
+data/metadata.sample.tsv
+
# Secrets and local env (keep .env.example tracked)
.env
.env.*
diff --git a/Dockerfile b/Dockerfile
@@ -0,0 +1,31 @@
+FROM node:22-bookworm-slim AS build
+
+RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+# Mount or COPY data/metadata.tsv before build in production
+RUN npm run index-metadata -- --if-stale || true
+RUN npm run build
+
+FROM node:22-bookworm-slim AS runtime
+
+WORKDIR /app
+ENV NODE_ENV=production
+ENV SERVE_STATIC=true
+ENV PORT_INDEX=0
+
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev && npm cache clean --force
+
+COPY --from=build /app/dist ./dist
+COPY --from=build /app/dist-server ./dist-server
+COPY --from=build /app/data ./data
+COPY --from=build /app/public ./public
+COPY --from=build /app/ecosystem.config.cjs ./
+
+EXPOSE 38471
+CMD ["node", "dist-server/server/index.js"]
diff --git a/README.md b/README.md
@@ -2,19 +2,26 @@
Retro-styled web player that picks random tracks from [`data/metadata.tsv`](data/metadata.tsv) and streams MP3s from the Internet Archive item [**The Myspace Dragon Hoard (2008–2010)**](https://archive.org/details/myspace_dragon_hoard_2010). URLs follow the same pattern as the official IA “Hobbit” player (ZIP member paths).
+The API uses a **SQLite index** (`data/tracks.db`) built from the TSV for fast random selection, search, and lookup by id — without loading hundreds of thousands of rows into RAM.
+
## Requirements
- Node.js 20+
-- `data/metadata.tsv` in this repo (Dragon Hoard export). No local MP3 mirror is required.
+- `data/metadata.tsv` (Dragon Hoard export). No local MP3 mirror is required.
+- After clone/deploy: run **`npm run index-metadata`** once (or on each TSV update) to create `data/tracks.db`.
## Configuration
1. Copy `.env.example` to `.env` and adjust:
- - `METADATA_TSV` — path to `metadata.tsv` (default: `data/metadata.tsv` inside this project).
- - **Ports** — defined in [`config/ports.ts`](config/ports.ts) as paired pools. Use `PORT_INDEX` (0–3) to pick a pair, or set `PORT` / `VITE_DEV_PORT` explicitly. Defaults: API `38471`, Vite dev `38472` (index 0).
+ - `METADATA_TSV` — path to `metadata.tsv` (default: `data/metadata.tsv`).
+ - `TRACKS_DB` — SQLite index (default: `data/tracks.db`).
+ - **Ports** — [`config/ports.ts`](config/ports.ts): `PORT_INDEX` (0–3) or explicit `PORT` / `VITE_DEV_PORT`. Defaults: API `38471`, Vite `38472`.
- `IA_ITEM_ID` (optional) — Internet Archive item id (default `myspace_dragon_hoard_2010`).
- - `SERVE_STATIC` — if `dist/index.html` exists after `npm run build`, the app serves the SPA + `/api` automatically. Set `SERVE_STATIC=false` for API-only. Explicit `true`/`1` is optional; use it when you want a clear flag in PM2/systemd.
+ - `SERVE_STATIC` — serve SPA from `dist/` on the API port when built.
+ - `CORS_ORIGINS` — comma-separated API CORS origins (production defaults to the public site).
+ - `PUBLIC_SITE_URL` / `VITE_PUBLIC_SITE_URL` — canonical URL for share links, oEmbed, embed snippet.
+ - `VITE_EMBED_PARENT_ORIGIN` — restrict embed `postMessage` target (optional).
2. Install dependencies:
@@ -22,46 +29,68 @@ Retro-styled web player that picks random tracks from [`data/metadata.tsv`](data
npm install
```
+3. Build the track index (required before first API start):
+
+ ```bash
+ npm run index-metadata
+ ```
+
+ Use `npm run index-metadata -- --if-stale` to skip when the DB is newer than the TSV (used by `npm run build`). Use `--force` to rebuild always.
+
## Development
-Start the API and the Vite app together (`/api` → `http://localhost:38471` with default `PORT_INDEX=0`):
+Fast dev with a small TSV sample (optional):
+
+```bash
+npm run sample-metadata
+# set METADATA_TSV=data/metadata.sample.tsv in .env, then:
+npm run index-metadata -- --force
+```
+
+Start API + Vite:
```bash
npm run dev
```
-Open the URL Vite prints (by default `http://localhost:38472` with `PORT_INDEX=0`).
+Open the URL Vite prints (default `http://localhost:38472`).
## Production (static build + API)
-One process can serve both the SPA and `/api` after build:
-
```bash
npm run build
SERVE_STATIC=true npm run start
```
-Production runs **compiled JavaScript** (`node dist-server/server/index.js`), not `tsx`. Dev still uses `tsx watch server/index.ts` for the API hot reload.
+`npm run build` runs `index-metadata --if-stale`, compiles the server, and builds the SPA.
-Or use **PM2** (from this folder, after `npm run build`):
+**PM2** (after `npm run build`):
```bash
npm run pm2:prod
-# or: pm2 start ecosystem.config.cjs --env production
pm2 save
-pm2 startup
```
-Set `METADATA_TSV` to an **absolute path** on the server inside `ecosystem.config.cjs` → `env_production`, or use a `.env` next to the app.
+Set `METADATA_TSV` / `TRACKS_DB` in `ecosystem.config.cjs` → `env_production` on the VPS. Re-run **`npm run index-metadata`** when `metadata.tsv` changes.
+
+### Docker
+
+```bash
+docker compose build
+docker compose up
+```
+
+Ensure `data/metadata.tsv` is present under `./data` (volume). The image runs `index-metadata --if-stale` during build when the TSV is copied in.
### VPS: “No tracks available” / `trackCount: 0`
-1. Ensure **`data/metadata.tsv`** is present after `git clone` / deploy (large file; clone may take a while). If you omitted it, copy `metadata.tsv` next to the app and set **`METADATA_TSV`** to an **absolute path**. Relative paths resolve from **`process.cwd()`** (the app root).
-2. Restart the process and check **`GET /api/health`**: you should see `tracksReady: true`, `trackCount` > 0, `metadataExists: true`, and a `hint` if something is still wrong.
+1. Ensure **`data/metadata.tsv`** exists and run **`npm run index-metadata`**.
+2. Check **`GET /api/health`**: `tracksReady: true`, `tracksDbExists: true`, `trackCount` > 0, `ftsReady: true`, and `hint` if something failed.
+3. Restart PM2 after env changes: `pm2 restart mymusics --update-env`.
### Reverse proxy (e.g. `mymusics.murad.gg`)
-Point HTTPS at the Node port (default from pool index 0: `38471`, or whatever `PORT` / `PORT_INDEX` sets). Example **nginx**:
+Point HTTPS at the Node port (default `38471`). Example **nginx**:
```nginx
server {
@@ -77,55 +106,90 @@ server {
}
```
-No extra `vite.config` base URL is needed when the site is served at the domain root.
+**Embed on third-party sites:** HTML responses use `Content-Security-Policy: frame-ancestors *`. Ensure the proxy does not add `X-Frame-Options: DENY`.
-**Embed (`/embed`) on third-party sites:** The Node server sends `Content-Security-Policy: frame-ancestors *` on HTML responses so the player can be iframed. If the iframe still appears blank elsewhere, check that nginx (or another proxy) is **not** adding `X-Frame-Options: DENY` / `SAMEORIGIN` or a stricter `frame-ancestors` — those headers override or combine with the app’s policy.
+## Embed (`/embed`) — priority
-## Troubleshooting
+Iframe URL: `https://mymusics.murad.gg/embed` with optional query params:
-### HTTP 503 on `/api/track/random` (“No tracks available”)
+| Param | Default | Effect |
+|-------|---------|--------|
+| `autoplay` | `1` | `0` disables auto-advance and skips random load on mount |
+| `theme` | `default` | `compact` — smaller layout |
+| `start` | — | Track id — loads `GET /api/track/:id` |
+| `brand` | `1` | `0` hides footer logo |
+| `muted` | `0` | `1` starts muted |
-The API returns **503** only when the in-memory track pool is **empty** (`trackCount: 0`). The browser request is reaching Node; fix **metadata path and env** on the server.
+**postMessage** (iframe → parent), payload `{ source: "mymusics", type, ... }`:
-1. **On the VPS (SSH)**, call health locally (replace `38471` if you use another `PORT`):
+- `mymusics:ready` — `{ trackCount }`
+- `mymusics:track` — `{ id, title, artist, streamUrl }`
+- `mymusics:state` — `{ state: "playing" \| "paused" \| "buffering" \| "error" }`
+- `mymusics:error` — `{ code, message }`
- ```bash
- curl -sS http://127.0.0.1:38471/api/health
- ```
+Parent → iframe: `{ source: "mymusics-host", type: "mymusics:command", command: "play" \| "pause" \| "next" }`.
- Check `metadataTsv`, `metadataExists`, `metadataSizeBytes`, `trackCount`, `tracksReady`, and `hint`.
+**oEmbed:** `GET /api/oembed?url=https://mymusics.murad.gg/embed`
-2. **Align `METADATA_TSV`** with the real file (e.g. `/opt/mymusics/data/metadata.tsv`). Wrong paths such as `/opt/data/metadata.tsv` look “almost right” but **fail** if the file lives under the app directory. Alternatively **remove** `METADATA_TSV` from `.env` / PM2 so the app uses the default `data/metadata.tsv` next to the project. If the env path is missing but **`data/metadata.tsv` exists inside the app**, the server **falls back** to that file automatically and logs a warning (you should still fix `.env` to avoid confusion).
+The Home and About pages include a snippet generator with these options.
-3. **Restart the process** after editing env so variables reload:
+## API
- ```bash
- pm2 restart mymusics --update-env
- ```
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/health` | Diagnostics (`tracksDb`, `ftsReady`, `blockedCount`, …) |
+| GET | `/api/track/random` | Random track + `streamUrl` |
+| GET | `/api/track/up-next?exclude=` | Next track (prefers different id) |
+| GET | `/api/track/:id` | Track by id |
+| GET | `/api/track/search?q=&limit=` | Search by title/artist (FTS) |
+| POST | `/api/reload` | Reload DB / paths from env |
+| POST | `/api/events` | Client metrics (`stream_error`, `time_to_play`) |
+| GET | `/api/oembed?url=` | oEmbed JSON for `/embed` |
+
+## Share links
+
+- `https://mymusics.murad.gg/?track=TRACK_ID`
+- `https://mymusics.murad.gg/t/TRACK_ID` (redirects to `/?track=`)
+
+## Maintenance
+
+Weekly cron (optional) — sample Archive URLs and block failures:
+
+```bash
+npm run verify-tracks
+```
+
+Env: `VERIFY_SAMPLE_SIZE` (default `50`).
+
+## Troubleshooting
- (Use your PM2 app name if different.)
+### HTTP 503 on `/api/track/random`
-4. Re-run `curl` until `tracksReady` is `true` and `trackCount` > 0.
+The pool is empty. Run `npm run index-metadata` and verify `/api/health`.
-### Console: `content.js`, `classifier.js`, and TensorFlow / WebGL kernel messages
+### Console: TensorFlow / `content.js` messages
-Messages such as **“The kernel '…' for backend 'cpu' / 'webgl' is already registered”** (often under **`content.js`**) or **“Platform browser has already been set”** (under **`classifier.js`**) come from **browser extensions** that bundle TensorFlow.js — **not from MyMusics** (this repo does not ship TensorFlow). To confirm, open a **private/incognito** window with extensions disabled for that session, or turn extensions off temporarily.
+These come from **browser extensions**, not MyMusics.
## Playback notes
-- The browser loads audio directly from `https://archive.org/download/...` URLs. First play may be slow while the Archive serves the file from inside large ZIPs.
-- **HTTP 503 (or other failures) on the MP3 URL** come from **Internet Archive** (overload, ZIP member extraction, etc.), not from this app’s API. The player may auto-skip to another random track a few times; use **Next** if streaming keeps failing.
-- This is **not** DRM; users can still capture network traffic or use devtools.
+- Audio streams from `https://archive.org/download/...`. First play can be slow (ZIP member extraction).
+- Archive **503** errors are retried automatically a few times; use **Next** if needed.
+- Volume is remembered in `localStorage`. Shortcuts on Home: Space, N, M.
## Scripts
-| Command | Description |
-|-------------------|--------------------------------------------------|
-| `npm run dev` | Vite + API with hot reload |
-| `npm run build` | Production frontend build |
-| `npm run start` | API (`node dist-server/server/index.js` — run `npm run build` first); `SERVE_STATIC=true` also serves `dist/` |
-| `npm run build:server` | Compile API + `config/ports` to `dist-server/` (included in `npm run build`) |
-| `npm run pm2:prod`| `npm run build` then PM2 with `ecosystem.config.cjs` |
+| Command | Description |
+|---------|-------------|
+| `npm run dev` | Vite + API |
+| `npm run index-metadata` | Build `data/tracks.db` from TSV |
+| `npm run index-metadata:force` | Force rebuild index |
+| `npm run sample-metadata` | Write `data/metadata.sample.tsv` (500 lines) |
+| `npm run verify-tracks` | HEAD sample URLs → `blocked_ids` |
+| `npm run build` | Index (if stale) + server + SPA |
+| `npm run start` | Production Node server |
+| `npm run test` | Vitest (metadata + track store) |
+| `npm run pm2:prod` | Build + PM2 |
## Logo
diff --git a/docker-compose.yml b/docker-compose.yml
@@ -0,0 +1,13 @@
+services:
+ mymusics:
+ build: .
+ ports:
+ - "38471:38471"
+ environment:
+ NODE_ENV: production
+ SERVE_STATIC: "true"
+ PORT_INDEX: "0"
+ METADATA_TSV: /app/data/metadata.tsv
+ TRACKS_DB: /app/data/tracks.db
+ volumes:
+ - ./data:/app/data
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
@@ -17,7 +17,9 @@ module.exports = {
// Uses predefined pool in config/ports.ts — set PORT_INDEX 0..3 or explicit PORT
PORT_INDEX: "0",
SERVE_STATIC: "true",
- // Required on VPS: repo has no metadata.tsv — upload the TSV and set absolute path:
+ TRACKS_DB: "data/tracks.db",
+ // After deploy: npm run index-metadata (or index-metadata --if-stale) when metadata.tsv changes
+ // Required on VPS if metadata is outside the repo:
// METADATA_TSV: "/var/www/mymusics-data/metadata.tsv",
},
},
diff --git a/index.html b/index.html
@@ -5,6 +5,12 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyMusics</title>
+ <link
+ rel="alternate"
+ type="application/json+oembed"
+ href="https://mymusics.murad.gg/api/oembed?url=https://mymusics.murad.gg/embed"
+ title="MyMusics oEmbed"
+ />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
diff --git a/package-lock.json b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.1.3",
+ "better-sqlite3": "^12.6.2",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"react": "^19.2.5",
@@ -18,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -30,7 +32,8 @@
"tsx": "^4.21.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
- "vite": "^8.0.10"
+ "vite": "^8.0.10",
+ "vitest": "^4.1.0"
}
},
"node_modules/@babel/code-frame": {
@@ -1535,6 +1538,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1546,6 +1556,34 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1866,6 +1904,119 @@
}
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+ "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+ "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+ "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.7",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+ "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+ "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+ "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -1977,6 +2128,16 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -2015,6 +2176,26 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
@@ -2028,6 +2209,40 @@
"node": ">=6.0.0"
}
},
+ "node_modules/better-sqlite3": {
+ "version": "12.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
+ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -2074,6 +2289,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
@@ -2095,6 +2334,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2125,6 +2374,12 @@
"node": ">=8"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -2258,6 +2513,30 @@
}
}
},
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2287,7 +2566,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -2319,6 +2597,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -2562,6 +2856,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2572,6 +2876,25 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -2770,6 +3093,12 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
"node_modules/find-my-way": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
@@ -2822,6 +3151,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2870,6 +3205,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
@@ -2960,6 +3301,26 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2986,6 +3347,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
@@ -3468,6 +3835,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@@ -3480,6 +3857,18 @@
"node": ">=10.0.0"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -3495,6 +3884,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -3504,6 +3902,12 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3530,6 +3934,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -3537,6 +3947,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-abi": {
+ "version": "3.92.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
+ "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-abi/node_modules/semver": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+ "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
@@ -3544,6 +3978,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -3553,6 +3998,15 @@
"node": ">=14.0.0"
}
},
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3648,6 +4102,13 @@
"node": "20 || >=22"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3734,6 +4195,33 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3760,6 +4248,16 @@
],
"license": "MIT"
},
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3776,6 +4274,21 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@@ -3835,6 +4348,20 @@
"react-dom": ">=18"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -3949,6 +4476,26 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
@@ -4060,6 +4607,58 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -4088,6 +4687,13 @@
"node": ">= 10.x"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4097,6 +4703,22 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4125,6 +4747,15 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -4141,6 +4772,34 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@@ -4153,6 +4812,23 @@
"node": ">=20"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -4170,6 +4846,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/toad-cache": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
@@ -4238,6 +4924,18 @@
"fsevents": "~2.3.3"
}
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4337,6 +5035,12 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
@@ -4415,6 +5119,96 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.7",
+ "@vitest/mocker": "4.1.7",
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/runner": "4.1.7",
+ "@vitest/snapshot": "4.1.7",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.7",
+ "@vitest/browser-preview": "4.1.7",
+ "@vitest/browser-webdriverio": "4.1.7",
+ "@vitest/coverage-istanbul": "4.1.7",
+ "@vitest/coverage-v8": "4.1.7",
+ "@vitest/ui": "4.1.7",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4431,6 +5225,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4459,6 +5270,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
@@ -9,7 +9,13 @@
"dev:web": "vite",
"dev:vite": "vite",
"build:server": "tsc -p tsconfig.server.json",
- "build": "npm run build:server && tsc -b && vite build",
+ "index-metadata": "tsx scripts/index-metadata.ts",
+ "index-metadata:force": "tsx scripts/index-metadata.ts --force",
+ "sample-metadata": "tsx scripts/sample-metadata.ts",
+ "verify-tracks": "tsx scripts/verify-tracks.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "build": "npm run index-metadata -- --if-stale && npm run build:server && tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start": "node dist-server/server/index.js",
@@ -18,6 +24,7 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.1.3",
+ "better-sqlite3": "^12.6.2",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"react": "^19.2.5",
@@ -26,7 +33,9 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.12.2",
+ "vitest": "^4.1.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
diff --git a/scripts/index-metadata.ts b/scripts/index-metadata.ts
@@ -0,0 +1,106 @@
+import fs from "node:fs";
+import path from "node:path";
+import readline from "node:readline";
+
+import dotenv from "dotenv";
+
+import { IA_DRAGON_HOARD_ID, parseTrackLine } from "../server/metadata.js";
+import { WritableTrackStore } from "../server/trackStore.js";
+
+const PROJECT_ROOT = process.cwd();
+dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
+
+function resolvePath(p: string): string {
+ return path.isAbsolute(p) ? p : path.resolve(PROJECT_ROOT, p);
+}
+
+const BUNDLED_METADATA_TSV = path.join(PROJECT_ROOT, "data", "metadata.tsv");
+const BUNDLED_TRACKS_DB = path.join(PROJECT_ROOT, "data", "tracks.db");
+
+function resolveMetadataTsv(): string {
+ const raw = process.env.METADATA_TSV?.trim();
+ if (!raw) return BUNDLED_METADATA_TSV;
+ const resolved = resolvePath(raw);
+ if (fs.existsSync(resolved)) return resolved;
+ if (fs.existsSync(BUNDLED_METADATA_TSV)) return BUNDLED_METADATA_TSV;
+ return resolved;
+}
+
+function resolveTracksDb(): string {
+ const raw = process.env.TRACKS_DB?.trim();
+ return raw ? resolvePath(raw) : BUNDLED_TRACKS_DB;
+}
+
+function isStale(tsvPath: string, dbPath: string): boolean {
+ if (!fs.existsSync(dbPath)) return true;
+ if (!fs.existsSync(tsvPath)) return false;
+ const tsvMtime = fs.statSync(tsvPath).mtimeMs;
+ const dbMtime = fs.statSync(dbPath).mtimeMs;
+ return tsvMtime > dbMtime;
+}
+
+async function indexFromTsv(tsvPath: string, dbPath: string, itemId: string): Promise<number> {
+ const store = new WritableTrackStore(dbPath);
+ store.open();
+ store.clearTracks();
+
+ const BATCH = 5000;
+ let batch: ReturnType<typeof parseTrackLine>[] = [];
+ let total = 0;
+
+ const rl = readline.createInterface({
+ input: fs.createReadStream(tsvPath, { encoding: "utf-8" }),
+ crlfDelay: Infinity,
+ });
+
+ for await (const line of rl) {
+ const track = parseTrackLine(line, itemId);
+ if (!track) continue;
+ batch.push(track);
+ if (batch.length >= BATCH) {
+ store.insertBatch(batch.filter(Boolean) as NonNullable<typeof track>[]);
+ total += batch.length;
+ batch = [];
+ if (total % 50_000 === 0) console.info(`MyMusics index: ${total} tracks…`);
+ }
+ }
+ if (batch.length > 0) {
+ store.insertBatch(batch as NonNullable<(typeof batch)[0]>[]);
+ total += batch.length;
+ }
+
+ console.info("MyMusics index: rebuilding FTS…");
+ store.finishIndex();
+ store.close();
+ return total;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const ifStale = args.includes("--if-stale");
+ const force = args.includes("--force");
+
+ const tsvPath = resolveMetadataTsv();
+ const dbPath = resolveTracksDb();
+ const itemId = process.env.IA_ITEM_ID?.trim() || IA_DRAGON_HOARD_ID;
+
+ if (!fs.existsSync(tsvPath)) {
+ console.error(`METADATA_TSV not found: ${tsvPath}`);
+ process.exit(1);
+ }
+
+ if (ifStale && !force && !isStale(tsvPath, dbPath)) {
+ console.info(`MyMusics index: ${dbPath} is up to date (use --force to rebuild).`);
+ return;
+ }
+
+ const t0 = Date.now();
+ console.info(`MyMusics index: ${tsvPath} → ${dbPath}`);
+ const count = await indexFromTsv(tsvPath, dbPath, itemId);
+ console.info(`MyMusics index: ${count} tracks in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/sample-metadata.ts b/scripts/sample-metadata.ts
@@ -0,0 +1,25 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import dotenv from "dotenv";
+
+const PROJECT_ROOT = process.cwd();
+dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
+
+const SOURCE = path.join(PROJECT_ROOT, "data", "metadata.tsv");
+const OUT = path.join(PROJECT_ROOT, "data", "metadata.sample.tsv");
+const MAX_LINES = Number(process.env.SAMPLE_LINES ?? "500");
+
+function main() {
+ if (!fs.existsSync(SOURCE)) {
+ console.error(`Source not found: ${SOURCE}`);
+ process.exit(1);
+ }
+ const raw = fs.readFileSync(SOURCE, "utf-8");
+ const lines = raw.split(/\r?\n/).filter((l) => l.trim());
+ const slice = lines.slice(0, MAX_LINES);
+ fs.writeFileSync(OUT, `${slice.join("\n")}\n`, "utf-8");
+ console.info(`Wrote ${slice.length} lines to ${OUT}`);
+}
+
+main();
diff --git a/scripts/verify-tracks.ts b/scripts/verify-tracks.ts
@@ -0,0 +1,62 @@
+import path from "node:path";
+
+import dotenv from "dotenv";
+
+import { resolveTracksDb } from "../server/paths.js";
+import { TrackStore } from "../server/trackStore.js";
+
+const PROJECT_ROOT = process.cwd();
+dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
+
+const SAMPLE_SIZE = Math.min(
+ 500,
+ Math.max(1, Number(process.env.VERIFY_SAMPLE_SIZE ?? "50")),
+);
+const TIMEOUT_MS = 15_000;
+
+async function headOk(url: string): Promise<boolean> {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(url, {
+ method: "HEAD",
+ signal: ctrl.signal,
+ redirect: "follow",
+ });
+ return res.ok || res.status === 206;
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+async function main() {
+ const dbPath = resolveTracksDb(process.env, PROJECT_ROOT);
+ const store = new TrackStore(dbPath);
+ if (!store.exists()) {
+ console.error(`tracks.db not found: ${dbPath}`);
+ process.exit(1);
+ }
+ store.open();
+
+ const picks = store.sampleArchiveUrls(SAMPLE_SIZE);
+
+ let blocked = 0;
+ for (const row of picks) {
+ const ok = await headOk(row.archiveUrl);
+ if (!ok) {
+ store.blockId(row.id);
+ blocked += 1;
+ console.warn(`Blocked ${row.id} — HEAD failed`);
+ }
+ }
+
+ store.close();
+ console.info(`verify-tracks: checked ${picks.length}, blocked ${blocked}`);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/server/index.ts b/server/index.ts
@@ -6,152 +6,190 @@ import fastifyStatic from "@fastify/static";
import dotenv from "dotenv";
import Fastify from "fastify";
+import { buildOEmbedResponse } from "./oembed.js";
import {
IA_DRAGON_HOARD_ID,
loadTracksFromTsv,
type TrackMeta,
} from "./metadata.js";
+import {
+ bundledMetadataTsv,
+ getProjectRoot,
+ resolveEffectiveMetadataTsv,
+ resolveTracksDb,
+} from "./paths.js";
+import { rateLimit } from "./rateLimit.js";
+import { TrackStore } from "./trackStore.js";
import { resolveApiPort } from "../config/ports.js";
-/** App root — same as PM2 `cwd` / where you run `npm start` (not the compiled file path). */
-const PROJECT_ROOT = process.cwd();
+const PROJECT_ROOT = getProjectRoot();
dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
-function resolvePath(p: string): string {
- return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
-}
-
const PORT = resolveApiPort(process.env);
-/** Resolved path to data/metadata.tsv next to the app (independent of METADATA_TSV env). */
-const BUNDLED_METADATA_TSV = path.join(PROJECT_ROOT, "data", "metadata.tsv");
-
-/** If METADATA_TSV points to a missing file but the bundled copy exists, use bundled (VPS typo self-heal). */
-function resolveEffectiveMetadataTsv(): {
- path: string;
- envRequested: string | null;
- usedFallback: boolean;
-} {
- const raw = process.env.METADATA_TSV?.trim();
- if (!raw) {
- return { path: BUNDLED_METADATA_TSV, envRequested: null, usedFallback: false };
- }
- const resolved = resolvePath(raw);
- if (fs.existsSync(resolved)) {
- return { path: resolved, envRequested: resolved, usedFallback: false };
- }
- if (fs.existsSync(BUNDLED_METADATA_TSV)) {
- console.warn(
- `MyMusics: METADATA_TSV not found at ${resolved}; using bundled ${BUNDLED_METADATA_TSV}`,
- );
- return { path: BUNDLED_METADATA_TSV, envRequested: resolved, usedFallback: true };
- }
- return { path: resolved, envRequested: resolved, usedFallback: false };
-}
+const IA_ITEM_ID = process.env.IA_ITEM_ID?.trim() || IA_DRAGON_HOARD_ID;
+const PUBLIC_SITE_URL =
+ process.env.PUBLIC_SITE_URL?.trim() || "https://mymusics.murad.gg";
-/** Set at runtime in `main()` (and on `/api/reload`) so env matches PM2/dotenv; avoids load-time races. */
let METADATA_TSV = "";
let METADATA_ENV_REQUESTED: string | null = null;
let METADATA_USED_FALLBACK = false;
+let TRACKS_DB_PATH = "";
-function applyMetadataPathsFromEnv() {
+let store: TrackStore | null = null;
+let tsvFallbackPool: TrackMeta[] = [];
+let useTsvFallback = false;
+let metadataLoadHint: string | null = null;
+
+function applyPathsFromEnv() {
dotenv.config({ path: path.join(PROJECT_ROOT, ".env") });
- const m = resolveEffectiveMetadataTsv();
+ const m = resolveEffectiveMetadataTsv(process.env, PROJECT_ROOT);
METADATA_TSV = m.path;
METADATA_ENV_REQUESTED = m.envRequested;
METADATA_USED_FALLBACK = m.usedFallback;
+ TRACKS_DB_PATH = resolveTracksDb(process.env, PROJECT_ROOT);
}
function hintForMetadataNotFound(message: string): string {
if (!message.includes("not found")) return message;
- if (fs.existsSync(BUNDLED_METADATA_TSV) && METADATA_TSV !== BUNDLED_METADATA_TSV) {
- return `${message} Your METADATA_TSV points elsewhere, but the repo file exists at ${BUNDLED_METADATA_TSV}. Set METADATA_TSV to that path, or remove METADATA_TSV from .env/PM2 to use the default.`;
- }
- if (fs.existsSync(BUNDLED_METADATA_TSV)) {
- return `${message} (unexpected: bundled path exists; check permissions.)`;
+ const bundled = bundledMetadataTsv(PROJECT_ROOT);
+ if (fs.existsSync(bundled) && METADATA_TSV !== bundled) {
+ return `${message} Your METADATA_TSV points elsewhere, but the repo file exists at ${bundled}. Set METADATA_TSV to that path, or remove METADATA_TSV from .env/PM2 to use the default.`;
}
- return `${message} Expected a copy at ${BUNDLED_METADATA_TSV} relative to the app install.`;
+ return `${message} Run npm run index-metadata after placing metadata.tsv.`;
}
-const IA_ITEM_ID = process.env.IA_ITEM_ID?.trim() || IA_DRAGON_HOARD_ID;
-
-const distDir = path.join(PROJECT_ROOT, "dist");
-const distIndexPath = path.join(distDir, "index.html");
-const distExists = fs.existsSync(distIndexPath);
-
-/** Serve Vite build from dist/ when it exists (typical behind nginx). Opt out with SERVE_STATIC=false. */
-const staticDisabled =
- process.env.SERVE_STATIC === "false" || process.env.SERVE_STATIC === "0";
-const staticExplicit =
- process.env.SERVE_STATIC === "true" || process.env.SERVE_STATIC === "1";
-const serveStatic = !staticDisabled && (staticExplicit || distExists);
-
-let pool: TrackMeta[] = [];
-let metadataLoadHint: string | null = null;
function diagnoseEmptyMetadata(tsvPath: string) {
if (!fs.existsSync(tsvPath)) {
- return `File does not exist. Copy metadata.tsv from the Dragon Hoard dataset and set METADATA_TSV to an absolute path (e.g. /var/www/mymusics-data/metadata.tsv).`;
+ return `File does not exist. Copy metadata.tsv from the Dragon Hoard dataset and run npm run index-metadata.`;
}
const stat = fs.statSync(tsvPath);
if (stat.size === 0) return "File is empty (0 bytes).";
- const sample = fs.readFileSync(tsvPath, "utf8").slice(0, 8192);
- const firstLine = sample.split(/\r?\n/).find((l) => l.trim()) ?? "";
- const cols = firstLine.split("\t").length;
- if (cols < 4) {
- return `First data row has ${cols} tab-separated columns (need at least 4). If you opened the TSV in Excel, it may have been saved as CSV or with semicolons — restore tab-separated format. Preview: ${firstLine.slice(0, 120)}`;
- }
- const lastCol = firstLine.split("\t").pop() ?? "";
- if (!lastCol.includes("myspacecdn") || !lastCol.toLowerCase().includes(".mp3")) {
- return `Last column should be a MySpace CDN URL ending in .mp3. Preview of last column: ${lastCol.slice(0, 80)}`;
+ return "Rows parsed but no valid tracks in database. Run npm run index-metadata.";
+}
+
+function trackCount(): number {
+ if (store && !useTsvFallback) return store.count();
+ return tsvFallbackPool.length;
+}
+
+function pickRandom(excludeId?: string): TrackMeta | null {
+ if (store && !useTsvFallback) return store.random(excludeId);
+ if (!tsvFallbackPool.length) return null;
+ const ex = excludeId?.trim();
+ if (ex && tsvFallbackPool.length > 1) {
+ const filtered = tsvFallbackPool.filter((t) => t.id !== ex);
+ if (filtered.length > 0) {
+ return filtered[Math.floor(Math.random() * filtered.length)]!;
+ }
}
- return "Rows parsed but no valid Archive URLs (unexpected — check IA_ITEM_ID and CDN URL shape).";
+ return tsvFallbackPool[Math.floor(Math.random() * tsvFallbackPool.length)]!;
}
-function rebuildPool() {
+function getById(id: string): TrackMeta | null {
+ if (store && !useTsvFallback) return store.getById(id);
+ return tsvFallbackPool.find((t) => t.id === id.trim()) ?? null;
+}
+
+function searchTracks(q: string, limit: number) {
+ if (store && !useTsvFallback) return store.search(q, limit);
+ const trimmed = q.trim().toLowerCase();
+ if (trimmed.length < 2) return [];
+ return tsvFallbackPool
+ .filter(
+ (t) =>
+ t.title.toLowerCase().includes(trimmed) ||
+ t.artist.toLowerCase().includes(trimmed),
+ )
+ .slice(0, limit)
+ .map((t) => ({ id: t.id, title: t.title, artist: t.artist }));
+}
+
+function toTrackPayload(track: TrackMeta) {
+ return {
+ track: {
+ id: track.id,
+ title: track.title,
+ artist: track.artist,
+ fileKey: track.fileKey,
+ },
+ streamUrl: track.archiveUrl,
+ };
+}
+
+function rebuildStore() {
metadataLoadHint = null;
- pool = loadTracksFromTsv(METADATA_TSV, IA_ITEM_ID);
- console.info(`MyMusics: loaded ${pool.length} tracks from metadata (Internet Archive)`);
- if (pool.length === 0) {
- metadataLoadHint = diagnoseEmptyMetadata(METADATA_TSV);
- console.warn(`MyMusics: 0 tracks — ${metadataLoadHint}`);
+ applyPathsFromEnv();
+
+ if (fs.existsSync(TRACKS_DB_PATH)) {
+ store?.close();
+ store = new TrackStore(TRACKS_DB_PATH);
+ store.open();
+ useTsvFallback = false;
+ const count = store.count();
+ console.info(`MyMusics: ${count} tracks from SQLite ${TRACKS_DB_PATH}`);
+ if (count === 0) {
+ metadataLoadHint = diagnoseEmptyMetadata(METADATA_TSV);
+ console.warn(`MyMusics: 0 tracks — ${metadataLoadHint}`);
+ }
+ return;
}
-}
-function randomTrack(): TrackMeta | null {
- if (!pool.length) return null;
- return pool[Math.floor(Math.random() * pool.length)]!;
+ console.warn(
+ `MyMusics: ${TRACKS_DB_PATH} missing — falling back to TSV (slow). Run: npm run index-metadata`,
+ );
+ store?.close();
+ store = null;
+ useTsvFallback = true;
+ tsvFallbackPool = loadTracksFromTsv(METADATA_TSV, IA_ITEM_ID);
+ console.info(`MyMusics: loaded ${tsvFallbackPool.length} tracks from TSV fallback`);
+ if (tsvFallbackPool.length === 0) {
+ metadataLoadHint = diagnoseEmptyMetadata(METADATA_TSV);
+ }
}
-/** Prefer a track whose id differs from `excludeId` when the pool has more than one row. */
-function randomTrackExcluding(excludeId: string | undefined): TrackMeta | null {
- if (!pool.length) return null;
- if (!excludeId?.trim() || pool.length === 1) {
- return randomTrack();
+function resolveCorsOrigin(): boolean | string | RegExp | (string | RegExp)[] {
+ const raw = process.env.CORS_ORIGINS?.trim();
+ if (!raw) {
+ if (process.env.NODE_ENV === "production") {
+ return [PUBLIC_SITE_URL, "https://mymusics.murad.gg"];
+ }
+ return true;
}
- const filtered = pool.filter((t) => t.id !== excludeId);
- if (filtered.length === 0) return randomTrack();
- return filtered[Math.floor(Math.random() * filtered.length)]!;
+ if (raw === "*") return true;
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
}
+const distDir = path.join(PROJECT_ROOT, "dist");
+const distIndexPath = path.join(distDir, "index.html");
+const distExists = fs.existsSync(distIndexPath);
+
+const staticDisabled =
+ process.env.SERVE_STATIC === "false" || process.env.SERVE_STATIC === "0";
+const staticExplicit =
+ process.env.SERVE_STATIC === "true" || process.env.SERVE_STATIC === "1";
+const serveStatic = !staticDisabled && (staticExplicit || distExists);
+
async function main() {
- applyMetadataPathsFromEnv();
+ applyPathsFromEnv();
console.info(`MyMusics: cwd=${process.cwd()}`);
- console.info(
- `MyMusics: metadata file ${METADATA_TSV}${METADATA_USED_FALLBACK ? ` (fallback; env had ${METADATA_ENV_REQUESTED})` : ""}`,
- );
+ console.info(`MyMusics: metadata ${METADATA_TSV}`);
+ console.info(`MyMusics: tracks db ${TRACKS_DB_PATH}`);
+
try {
- rebuildPool();
+ rebuildStore();
} catch (e) {
console.error(e);
- pool = [];
- const raw = e instanceof Error ? e.message : "Failed to load metadata (see server logs).";
+ store = null;
+ useTsvFallback = true;
+ tsvFallbackPool = [];
+ const raw = e instanceof Error ? e.message : "Failed to load tracks.";
metadataLoadHint = hintForMetadataNotFound(raw);
}
const app = Fastify({ logger: true });
- await app.register(cors, { origin: true });
+ await app.register(cors, { origin: resolveCorsOrigin() });
- /** Allow embedding the SPA (e.g. /embed iframe on third-party sites). Strip anti-framing headers on HTML. */
app.addHook("onSend", async (_request, reply, payload) => {
const ct = reply.getHeader("content-type");
const ctStr = Array.isArray(ct) ? ct[0] : ct;
@@ -170,10 +208,16 @@ async function main() {
} catch {
metadataSizeBytes = null;
}
+ const count = trackCount();
return {
ok: true,
- trackCount: pool.length,
- tracksReady: pool.length > 0,
+ trackCount: count,
+ tracksReady: count > 0,
+ tracksDb: TRACKS_DB_PATH,
+ tracksDbExists: fs.existsSync(TRACKS_DB_PATH),
+ useTsvFallback,
+ ftsReady: store ? store.ftsReady() : false,
+ blockedCount: store && !useTsvFallback ? store.blockedCount() : 0,
metadataTsv: METADATA_TSV,
...(METADATA_ENV_REQUESTED && METADATA_ENV_REQUESTED !== METADATA_TSV
? { metadataEnvRequested: METADATA_ENV_REQUESTED, metadataUsedFallback: METADATA_USED_FALLBACK }
@@ -182,59 +226,93 @@ async function main() {
metadataSizeBytes,
cwd: process.cwd(),
iaItemId: IA_ITEM_ID,
- ...(metadataLoadHint ? { hint: metadataLoadHint } : {}),
+ hint:
+ metadataLoadHint ??
+ (!fs.existsSync(TRACKS_DB_PATH) && !useTsvFallback
+ ? "Run npm run index-metadata to build data/tracks.db"
+ : undefined),
};
});
app.post("/api/reload", async (_req, reply) => {
try {
- applyMetadataPathsFromEnv();
- const next = loadTracksFromTsv(METADATA_TSV, IA_ITEM_ID);
- pool = next;
- console.info(`MyMusics: reloaded ${pool.length} tracks from metadata`);
- return reply.send({ ok: true, trackCount: pool.length });
+ rebuildStore();
+ return reply.send({ ok: true, trackCount: trackCount() });
} catch (e) {
- const message = e instanceof Error ? e.message : "Failed to reload metadata";
+ const message = e instanceof Error ? e.message : "Failed to reload";
return reply.code(500).send({ ok: false, error: message });
}
});
+ app.get("/api/track/search", async (req, reply) => {
+ const q = (req.query as { q?: string }).q ?? "";
+ const limitRaw = (req.query as { limit?: string }).limit;
+ const limit = limitRaw ? Math.min(50, Math.max(1, Number(limitRaw) || 20)) : 20;
+ return reply.send({ tracks: searchTracks(q, limit) });
+ });
+
app.get("/api/track/random", async (_req, reply) => {
- const track = randomTrack();
+ const track = pickRandom();
if (!track) {
return reply.code(503).send({
error: "No tracks available. Check that metadata loaded correctly.",
});
}
- return reply.send({
- track: {
- id: track.id,
- title: track.title,
- artist: track.artist,
- fileKey: track.fileKey,
- },
- streamUrl: track.archiveUrl,
- });
+ return reply.send(toTrackPayload(track));
});
app.get("/api/track/up-next", async (req, reply) => {
const raw = (req.query as { exclude?: string }).exclude;
const excludeId = typeof raw === "string" ? raw.trim() : undefined;
- const track = randomTrackExcluding(excludeId);
+ const track = pickRandom(excludeId);
if (!track) {
return reply.code(503).send({
error: "No tracks available. Check that metadata loaded correctly.",
});
}
- return reply.send({
- track: {
- id: track.id,
- title: track.title,
- artist: track.artist,
- fileKey: track.fileKey,
- },
- streamUrl: track.archiveUrl,
- });
+ return reply.send(toTrackPayload(track));
+ });
+
+ app.get("/api/track/:id", async (req, reply) => {
+ const id = (req.params as { id: string }).id?.trim();
+ if (!id) return reply.code(400).send({ error: "Missing track id" });
+ const track = getById(id);
+ if (!track) return reply.code(404).send({ error: "Track not found" });
+ return reply.send(toTrackPayload(track));
+ });
+
+ app.post("/api/events", async (req, reply) => {
+ const ip = req.ip;
+ const rl = rateLimit(`events:${ip}`, 60, 60_000);
+ if (!rl.ok) {
+ return reply.code(429).send({ error: "Too many events", retryAfterSec: rl.retryAfterSec });
+ }
+ const body = req.body as {
+ type?: string;
+ trackId?: string;
+ detail?: string;
+ ms?: number;
+ };
+ const type = body?.type?.trim();
+ if (type !== "stream_error" && type !== "time_to_play") {
+ return reply.code(400).send({ error: "Invalid event type" });
+ }
+ req.log.info({ event: type, trackId: body.trackId, detail: body.detail, ms: body.ms });
+ return reply.send({ ok: true });
+ });
+
+ app.get("/api/oembed", async (req, reply) => {
+ const url = (req.query as { url?: string }).url ?? "";
+ const data = buildOEmbedResponse(url, PUBLIC_SITE_URL);
+ if (!data) return reply.code(404).send({ error: "URL not supported for oEmbed" });
+ return reply.send(data);
+ });
+
+ app.get("/.well-known/oembed", async (req, reply) => {
+ const url = (req.query as { url?: string }).url ?? "";
+ const data = buildOEmbedResponse(url, PUBLIC_SITE_URL);
+ if (!data) return reply.code(404).send({ error: "URL not supported for oEmbed" });
+ return reply.send(data);
});
if (serveStatic) {
@@ -256,14 +334,6 @@ async function main() {
"MyMusics: SERVE_STATIC requested but dist/index.html is missing — run `npm run build` on the server.",
);
}
- } else if (distExists) {
- console.info(
- "MyMusics: dist/ exists but SPA is disabled (SERVE_STATIC=false); GET / returns 404, /api only.",
- );
- } else {
- console.info(
- "MyMusics: API only (no dist/). Use `npm run build` or set up Vite dev; nginx should not proxy / to this port until SPA is served.",
- );
}
await app.listen({ port: PORT, host: "0.0.0.0" });
diff --git a/server/metadata.test.ts b/server/metadata.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import { buildArchiveDownloadUrl, parseTrackLine } from "./metadata.js";
+
+describe("buildArchiveDownloadUrl", () => {
+ it("builds ZIP member URL from MySpace CDN path", () => {
+ const url =
+ "http://cache06-music02.myspacecdn.com/46/std_1f69563352d19cb0132334cd0d3adeaf.mp3";
+ const out = buildArchiveDownloadUrl(url);
+ expect(out).toBe(
+ "https://archive.org/download/myspace_dragon_hoard_2010/46.zip/46%2Fstd_1f69563352d19cb0132334cd0d3adeaf.mp3",
+ );
+ });
+
+ it("returns null for invalid URLs", () => {
+ expect(buildArchiveDownloadUrl("not-a-url")).toBeNull();
+ expect(buildArchiveDownloadUrl("https://example.com/foo.txt")).toBeNull();
+ });
+});
+
+describe("parseTrackLine", () => {
+ it("parses a valid TSV line", () => {
+ const line =
+ "1\tBig Yellow Moon\t78393366\tbill nelson\twww.myspace.com/x\t0\tmyspace\t25796\thttp://cache06-music02.myspacecdn.com/46/std_1f69563352d19cb0132334cd0d3adeaf.mp3";
+ const t = parseTrackLine(line);
+ expect(t).not.toBeNull();
+ expect(t!.id).toBe("1");
+ expect(t!.title).toBe("Big Yellow Moon");
+ expect(t!.artist).toBe("bill nelson");
+ expect(t!.fileKey).toMatch(/\.mp3$/i);
+ });
+});
diff --git a/server/metadata.ts b/server/metadata.ts
@@ -46,6 +46,29 @@ export function buildArchiveDownloadUrl(
return `https://archive.org/download/${itemId}/${collection}.zip/${encodeURIComponent(`${collection}/${fname}`)}`;
}
+/** Parse one TSV line into track metadata, or null if invalid. */
+export function parseTrackLine(line: string, itemId: string = IA_DRAGON_HOARD_ID): TrackMeta | null {
+ if (!line.trim()) return null;
+ const parts = line.split("\t");
+ if (parts.length < 4) return null;
+ const id = parts[0]!;
+ const title = parts[1]!;
+ const artist = parts[3]!;
+ const cdnUrl = parts[parts.length - 1]!;
+ const archiveUrl = buildArchiveDownloadUrl(cdnUrl, itemId);
+ if (!archiveUrl) return null;
+ const bn = basenameFromUrl(cdnUrl);
+ if (!bn) return null;
+ return {
+ id,
+ title,
+ artist,
+ fileKey: bn,
+ cdnUrl,
+ archiveUrl,
+ };
+}
+
/**
* Read metadata.tsv and return every track that maps to a valid Internet Archive URL.
* Does not require local MP3 files.
@@ -59,25 +82,8 @@ export function loadTracksFromTsv(tsvPath: string, itemId: string = IA_DRAGON_HO
const raw = fs.readFileSync(tsvPath, "utf-8");
const lines = raw.split(/\r?\n/);
for (const line of lines) {
- if (!line.trim()) continue;
- const parts = line.split("\t");
- if (parts.length < 4) continue;
- const id = parts[0]!;
- const title = parts[1]!;
- const artist = parts[3]!;
- const cdnUrl = parts[parts.length - 1]!;
- const archiveUrl = buildArchiveDownloadUrl(cdnUrl, itemId);
- if (!archiveUrl) continue;
- const bn = basenameFromUrl(cdnUrl);
- if (!bn) continue;
- out.push({
- id,
- title,
- artist,
- fileKey: bn,
- cdnUrl,
- archiveUrl,
- });
+ const track = parseTrackLine(line, itemId);
+ if (track) out.push(track);
}
return out;
}
diff --git a/server/oembed.ts b/server/oembed.ts
@@ -0,0 +1,35 @@
+const DEFAULT_SITE = "https://mymusics.murad.gg";
+
+export function buildOEmbedResponse(
+ requestUrl: string,
+ siteOrigin: string = process.env.PUBLIC_SITE_URL?.trim() || DEFAULT_SITE,
+): Record<string, unknown> | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(requestUrl);
+ } catch {
+ return null;
+ }
+ const allowedHosts = new Set([
+ new URL(siteOrigin).host,
+ "mymusics.murad.gg",
+ "localhost",
+ "127.0.0.1",
+ ]);
+ if (!allowedHosts.has(parsed.host)) return null;
+ if (!parsed.pathname.startsWith("/embed")) return null;
+
+ const iframeSrc = `${siteOrigin.replace(/\/$/, "")}/embed${parsed.search}`;
+ const html = `<iframe src="${iframeSrc}" title="MyMusics" width="380" height="540" style="max-width:100%;border:0;border-radius:12px" loading="lazy" allow="autoplay"></iframe>`;
+
+ return {
+ version: "1.0",
+ type: "rich",
+ provider_name: "MyMusics",
+ provider_url: siteOrigin,
+ title: "MyMusics Player",
+ width: 380,
+ height: 540,
+ html,
+ };
+}
diff --git a/server/paths.ts b/server/paths.ts
@@ -0,0 +1,43 @@
+import fs from "node:fs";
+import path from "node:path";
+
+export function getProjectRoot(): string {
+ return process.cwd();
+}
+
+export function resolvePath(p: string, root = getProjectRoot()): string {
+ return path.isAbsolute(p) ? p : path.resolve(root, p);
+}
+
+export function bundledMetadataTsv(root = getProjectRoot()): string {
+ return path.join(root, "data", "metadata.tsv");
+}
+
+export function bundledTracksDb(root = getProjectRoot()): string {
+ return path.join(root, "data", "tracks.db");
+}
+
+export function resolveEffectiveMetadataTsv(
+ env: NodeJS.ProcessEnv,
+ root = getProjectRoot(),
+): { path: string; envRequested: string | null; usedFallback: boolean } {
+ const bundled = bundledMetadataTsv(root);
+ const raw = env.METADATA_TSV?.trim();
+ if (!raw) {
+ return { path: bundled, envRequested: null, usedFallback: false };
+ }
+ const resolved = resolvePath(raw, root);
+ if (fs.existsSync(resolved)) {
+ return { path: resolved, envRequested: resolved, usedFallback: false };
+ }
+ if (fs.existsSync(bundled)) {
+ console.warn(`MyMusics: METADATA_TSV not found at ${resolved}; using bundled ${bundled}`);
+ return { path: bundled, envRequested: resolved, usedFallback: true };
+ }
+ return { path: resolved, envRequested: resolved, usedFallback: false };
+}
+
+export function resolveTracksDb(env: NodeJS.ProcessEnv, root = getProjectRoot()): string {
+ const raw = env.TRACKS_DB?.trim();
+ return raw ? resolvePath(raw, root) : bundledTracksDb(root);
+}
diff --git a/server/rateLimit.ts b/server/rateLimit.ts
@@ -0,0 +1,19 @@
+const buckets = new Map<string, { count: number; resetAt: number }>();
+
+export function rateLimit(
+ key: string,
+ maxPerWindow: number,
+ windowMs: number,
+): { ok: true } | { ok: false; retryAfterSec: number } {
+ const now = Date.now();
+ let b = buckets.get(key);
+ if (!b || now >= b.resetAt) {
+ b = { count: 0, resetAt: now + windowMs };
+ buckets.set(key, b);
+ }
+ b.count += 1;
+ if (b.count > maxPerWindow) {
+ return { ok: false, retryAfterSec: Math.ceil((b.resetAt - now) / 1000) };
+ }
+ return { ok: true };
+}
diff --git a/server/trackStore.test.ts b/server/trackStore.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+
+import type { TrackMeta } from "./metadata.js";
+import { WritableTrackStore } from "./trackStore.js";
+
+const SAMPLE: TrackMeta[] = [
+ {
+ id: "1",
+ title: "Moon Song",
+ artist: "Bill Nelson",
+ fileKey: "a.mp3",
+ cdnUrl: "http://cache06-music02.myspacecdn.com/46/std_a.mp3",
+ archiveUrl: "https://archive.org/download/myspace_dragon_hoard_2010/46.zip/46%2Fa.mp3",
+ },
+ {
+ id: "2",
+ title: "Fire Track",
+ artist: "Other Artist",
+ fileKey: "b.mp3",
+ cdnUrl: "http://cache06-music02.myspacecdn.com/46/std_b.mp3",
+ archiveUrl: "https://archive.org/download/myspace_dragon_hoard_2010/46.zip/46%2Fb.mp3",
+ },
+];
+
+describe("TrackStore", () => {
+ let store: WritableTrackStore;
+
+ beforeEach(() => {
+ store = new WritableTrackStore(":memory:");
+ store.open();
+ store.insertBatch(SAMPLE);
+ store.finishIndex();
+ });
+
+ afterEach(() => {
+ store.close();
+ });
+
+ it("counts tracks", () => {
+ expect(store.count()).toBe(2);
+ });
+
+ it("gets by id", () => {
+ const t = store.getById("1");
+ expect(t?.title).toBe("Moon Song");
+ });
+
+ it("searches by artist", () => {
+ const hits = store.search("Bill", 10);
+ expect(hits.some((h) => h.id === "1")).toBe(true);
+ });
+
+ it("excludes blocked ids from random", () => {
+ store.blockId("1");
+ const t = store.random();
+ expect(t?.id).toBe("2");
+ });
+});
diff --git a/server/trackStore.ts b/server/trackStore.ts
@@ -0,0 +1,253 @@
+import fs from "node:fs";
+
+import Database from "better-sqlite3";
+
+import type { TrackMeta } from "./metadata.js";
+
+export const TRACKS_SCHEMA_SQL = `
+CREATE TABLE IF NOT EXISTS tracks (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ artist TEXT NOT NULL,
+ file_key TEXT NOT NULL,
+ cdn_url TEXT NOT NULL,
+ archive_url TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS blocked_ids (
+ id TEXT PRIMARY KEY
+);
+`;
+
+export function initTracksDb(db: Database.Database): void {
+ db.exec(TRACKS_SCHEMA_SQL);
+ db.exec(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS tracks_fts USING fts5(
+ title,
+ artist,
+ tokenize='unicode61'
+ );
+ `);
+}
+
+export function rebuildFts(db: Database.Database): void {
+ db.exec(`DELETE FROM tracks_fts;`);
+ db.exec(`
+ INSERT INTO tracks_fts(rowid, title, artist)
+ SELECT rowid, title, artist FROM tracks;
+ `);
+}
+
+export class TrackStore {
+ protected db: Database.Database | null = null;
+
+ constructor(private readonly dbPath: string) {}
+
+ open(): void {
+ if (this.db) return;
+ if (this.dbPath !== ":memory:") {
+ const dir = this.dbPath.replace(/[/\\][^/\\]+$/, "");
+ if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ }
+ this.db = new Database(this.dbPath, { readonly: false });
+ this.db.pragma("journal_mode = WAL");
+ initTracksDb(this.db);
+ }
+
+ close(): void {
+ this.db?.close();
+ this.db = null;
+ }
+
+ reload(): void {
+ this.close();
+ this.open();
+ }
+
+ exists(): boolean {
+ return fs.existsSync(this.dbPath);
+ }
+
+ get path(): string {
+ return this.dbPath;
+ }
+
+ protected requireDb(): Database.Database {
+ if (!this.db) throw new Error("TrackStore not open");
+ return this.db;
+ }
+
+ count(): number {
+ const row = this.requireDb()
+ .prepare(
+ `SELECT COUNT(*) AS c FROM tracks t
+ WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)`,
+ )
+ .get() as { c: number };
+ return row.c;
+ }
+
+ blockedCount(): number {
+ const row = this.requireDb().prepare(`SELECT COUNT(*) AS c FROM blocked_ids`).get() as {
+ c: number;
+ };
+ return row.c;
+ }
+
+ ftsReady(): boolean {
+ try {
+ const row = this.requireDb()
+ .prepare(`SELECT COUNT(*) AS c FROM tracks_fts`)
+ .get() as { c: number };
+ return row.c > 0;
+ } catch {
+ return false;
+ }
+ }
+
+ getById(id: string): TrackMeta | null {
+ const row = this.requireDb()
+ .prepare(
+ `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t
+ WHERE t.id = ? AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)`,
+ )
+ .get(id.trim()) as Row | undefined;
+ return row ? rowToMeta(row) : null;
+ }
+
+ random(excludeId?: string): TrackMeta | null {
+ const db = this.requireDb();
+ const ex = excludeId?.trim();
+ if (ex) {
+ const row = db
+ .prepare(
+ `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t
+ WHERE t.id != ?
+ AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ ORDER BY RANDOM() LIMIT 1`,
+ )
+ .get(ex) as Row | undefined;
+ if (row) return rowToMeta(row);
+ }
+ const row = db
+ .prepare(
+ `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t
+ WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ ORDER BY RANDOM() LIMIT 1`,
+ )
+ .get() as Row | undefined;
+ return row ? rowToMeta(row) : null;
+ }
+
+ search(q: string, limit = 20): Pick<TrackMeta, "id" | "title" | "artist">[] {
+ const trimmed = q.trim();
+ if (trimmed.length < 2) return [];
+ const cap = Math.min(Math.max(1, limit), 50);
+ const db = this.requireDb();
+
+ try {
+ const ftsQuery = trimmed
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((w) => `"${w.replace(/"/g, '""')}"*`)
+ .join(" ");
+ const rows = db
+ .prepare(
+ `SELECT t.id, t.title, t.artist FROM tracks_fts f
+ JOIN tracks t ON t.rowid = f.rowid
+ WHERE tracks_fts MATCH ?
+ AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ LIMIT ?`,
+ )
+ .all(ftsQuery, cap) as SearchRow[];
+ if (rows.length > 0) return rows;
+ } catch {
+ /* fallback below */
+ }
+
+ const escaped = trimmed.replace(/[%_\\]/g, (c) => `\\${c}`);
+ const like = `%${escaped}%`;
+ return db
+ .prepare(
+ `SELECT id, title, artist FROM tracks t
+ WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\')
+ AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ LIMIT ?`,
+ )
+ .all(like, like, cap) as SearchRow[];
+ }
+
+ blockId(id: string): void {
+ this.requireDb()
+ .prepare(`INSERT OR IGNORE INTO blocked_ids (id) VALUES (?)`)
+ .run(id.trim());
+ }
+
+ sampleArchiveUrls(limit: number): { id: string; archiveUrl: string }[] {
+ return this.requireDb()
+ .prepare(
+ `SELECT id, archive_url AS archiveUrl FROM tracks
+ ORDER BY RANDOM() LIMIT ?`,
+ )
+ .all(limit) as { id: string; archiveUrl: string }[];
+ }
+}
+
+type Row = {
+ id: string;
+ title: string;
+ artist: string;
+ file_key: string;
+ cdn_url: string;
+ archive_url: string;
+};
+
+type SearchRow = { id: string; title: string; artist: string };
+
+function rowToMeta(row: Row): TrackMeta {
+ return {
+ id: row.id,
+ title: row.title,
+ artist: row.artist,
+ fileKey: row.file_key,
+ cdnUrl: row.cdn_url,
+ archiveUrl: row.archive_url,
+ };
+}
+
+/** Writable store for indexing (batch insert + FTS rebuild). */
+export class WritableTrackStore extends TrackStore {
+ insertBatch(tracks: TrackMeta[]): void {
+ const db = this.requireDb();
+ const insert = db.prepare(
+ `INSERT OR REPLACE INTO tracks (id, title, artist, file_key, cdn_url, archive_url)
+ VALUES (@id, @title, @artist, @fileKey, @cdnUrl, @archiveUrl)`,
+ );
+ const tx = db.transaction((rows: TrackMeta[]) => {
+ for (const t of rows) {
+ insert.run({
+ id: t.id,
+ title: t.title,
+ artist: t.artist,
+ fileKey: t.fileKey,
+ cdnUrl: t.cdnUrl,
+ archiveUrl: t.archiveUrl,
+ });
+ }
+ });
+ tx(tracks);
+ }
+
+ clearTracks(): void {
+ const db = this.requireDb();
+ db.exec(`DELETE FROM tracks;`);
+ try {
+ db.exec(`DELETE FROM tracks_fts;`);
+ } catch {
+ /* fts may not exist yet */
+ }
+ }
+
+ finishIndex(): void {
+ rebuildFts(this.requireDb());
+ }
+}
diff --git a/src/App.css b/src/App.css
@@ -65,6 +65,11 @@
}
.card-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
margin-bottom: 1rem;
}
@@ -675,3 +680,132 @@
.embed-snippet-copy {
margin-top: 0;
}
+
+.btn-share {
+ font-size: 0.85rem;
+ padding: 0.35rem 0.75rem;
+}
+
+.player-attribution {
+ margin: 0.75rem 0 0;
+ font-size: 0.8rem;
+ color: var(--muted, #888);
+}
+
+.player-attribution--compact {
+ margin-top: 0.5rem;
+ font-size: 0.72rem;
+}
+
+.player-attribution a {
+ color: inherit;
+}
+
+.player-phase {
+ font-size: 0.9rem;
+ color: var(--accent, #c9a227);
+ margin: 0.5rem 0 0;
+}
+
+.player-phase--compact,
+.hint--compact {
+ font-size: 0.8rem;
+}
+
+.player-keys-hint {
+ margin: 0.5rem 0 0;
+ font-size: 0.75rem;
+}
+
+.track-search {
+ margin-bottom: 1rem;
+}
+
+.track-search-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.5rem 0.75rem;
+ margin-top: 0.5rem;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(0, 0, 0, 0.25);
+ color: inherit;
+}
+
+.track-search-results {
+ list-style: none;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ max-height: 12rem;
+ overflow-y: auto;
+}
+
+.track-search-hit,
+.history-hit {
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 0.35rem 0;
+ font: inherit;
+}
+
+.track-search-hit:hover,
+.history-hit:hover {
+ text-decoration: underline;
+}
+
+.track-search-hint {
+ margin: 0.35rem 0 0;
+ font-size: 0.85rem;
+}
+
+.embed-snippet-options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.embed-snippet-start {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.9rem;
+}
+
+.embed-snippet-start input {
+ padding: 0.4rem 0.6rem;
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(0, 0, 0, 0.25);
+ color: inherit;
+}
+
+.embed-shell--compact .card {
+ padding: 0.65rem 0.85rem;
+}
+
+.embed-shell--compact .track-block .title {
+ font-size: 1rem;
+}
+
+.embed-shell--compact .track-block .artist {
+ font-size: 0.85rem;
+}
+
+.embed-shell--compact .up-next {
+ margin-top: 0.35rem;
+}
+
+.embed-shell--compact .embed-brand-logo {
+ max-height: 48px;
+ width: auto;
+}
+
+.health-banner--embed summary {
+ cursor: pointer;
+ font-weight: 600;
+}
diff --git a/src/App.tsx b/src/App.tsx
@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router-dom";
import About from "./pages/About";
import Embed from "./pages/Embed";
import Home from "./pages/Home";
+import TrackRedirect from "./pages/TrackRedirect";
export default function App() {
return (
@@ -9,6 +10,7 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/embed" element={<Embed />} />
+ <Route path="/t/:id" element={<TrackRedirect />} />
</Routes>
);
}
diff --git a/src/components/EmbedSnippet.tsx b/src/components/EmbedSnippet.tsx
@@ -1,8 +1,20 @@
-import { useCallback, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { PUBLIC_SITE_URL } from "../config/siteUrl";
+import { buildEmbedSearchParams } from "../lib/embedParams";
-function buildIframeSnippet(): string {
- const src = `${PUBLIC_SITE_URL}/embed`;
+function buildIframeSnippet(opts: {
+ autoplay: boolean;
+ compact: boolean;
+ startId: string;
+ showBrand: boolean;
+}): string {
+ const qs = buildEmbedSearchParams({
+ autoplay: opts.autoplay,
+ theme: opts.compact ? "compact" : "default",
+ startId: opts.startId.trim() || null,
+ showBrand: opts.showBrand,
+ });
+ const src = `${PUBLIC_SITE_URL}/embed${qs}`;
return `<iframe
src="${src}"
title="MyMusics"
@@ -10,12 +22,21 @@ function buildIframeSnippet(): string {
height="540"
style="max-width:380px;border:0;border-radius:12px"
loading="lazy"
+ allow="autoplay"
></iframe>`;
}
export function EmbedSnippet() {
const [copied, setCopied] = useState(false);
- const code = buildIframeSnippet();
+ const [autoplay, setAutoplay] = useState(true);
+ const [compact, setCompact] = useState(false);
+ const [showBrand, setShowBrand] = useState(true);
+ const [startId, setStartId] = useState("");
+
+ const code = useMemo(
+ () => buildIframeSnippet({ autoplay, compact, startId, showBrand }),
+ [autoplay, compact, startId, showBrand],
+ );
const copy = useCallback(async () => {
try {
@@ -44,11 +65,45 @@ export function EmbedSnippet() {
return (
<section className="embed-snippet card" aria-label="Embed this player">
<h2 className="embed-snippet-title">Embed on your site</h2>
- <p className="embed-snippet-lead muted">Paste this HTML wherever you want the player to appear.</p>
- <textarea className="embed-snippet-code" readOnly rows={7} value={code} spellCheck={false} />
+ <p className="embed-snippet-lead muted">
+ Paste this HTML wherever you want the player. Optional query params:{" "}
+ <code>autoplay=0</code>, <code>theme=compact</code>, <code>start=TRACK_ID</code>,{" "}
+ <code>brand=0</code>, <code>muted=1</code>. Parent page can listen for{" "}
+ <code>postMessage</code> events (<code>mymusics:track</code>, <code>mymusics:state</code>, etc.).
+ </p>
+
+ <div className="embed-snippet-options">
+ <label className="check">
+ <input type="checkbox" checked={autoplay} onChange={(e) => setAutoplay(e.target.checked)} />
+ Autoplay / auto-advance
+ </label>
+ <label className="check">
+ <input type="checkbox" checked={compact} onChange={(e) => setCompact(e.target.checked)} />
+ Compact theme
+ </label>
+ <label className="check">
+ <input type="checkbox" checked={showBrand} onChange={(e) => setShowBrand(e.target.checked)} />
+ Show MyMusics logo
+ </label>
+ <label className="embed-snippet-start">
+ <span>Start track id (optional)</span>
+ <input
+ type="text"
+ value={startId}
+ onChange={(e) => setStartId(e.target.value)}
+ placeholder="e.g. 12345"
+ spellCheck={false}
+ />
+ </label>
+ </div>
+
+ <textarea className="embed-snippet-code" readOnly rows={8} value={code} spellCheck={false} />
<button type="button" className="btn primary embed-snippet-copy" onClick={() => void copy()}>
{copied ? "Copied!" : "Copy code"}
</button>
+ <p className="embed-snippet-lead muted">
+ oEmbed: <code>{PUBLIC_SITE_URL}/api/oembed?url={encodeURIComponent(`${PUBLIC_SITE_URL}/embed`)}</code>
+ </p>
</section>
);
}
diff --git a/src/components/PlayerAttribution.tsx b/src/components/PlayerAttribution.tsx
@@ -0,0 +1,15 @@
+export function PlayerAttribution({ compact = false }: { compact?: boolean }) {
+ return (
+ <p className={`player-attribution${compact ? " player-attribution--compact" : ""}`}>
+ Audio from{" "}
+ <a
+ href="https://archive.org/details/myspace_dragon_hoard_2010"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Internet Archive — The Myspace Dragon Hoard
+ </a>
+ .
+ </p>
+ );
+}
diff --git a/src/components/PlayerStatus.tsx b/src/components/PlayerStatus.tsx
@@ -0,0 +1,29 @@
+import type { PlaybackPhase } from "../hooks/useMyMusicsPlayback";
+
+type Props = {
+ phase: PlaybackPhase;
+ status: string;
+ hasTrack: boolean;
+ compact?: boolean;
+};
+
+export function PlayerStatus({ phase, status, hasTrack, compact }: Props) {
+ if (phase === "loading" || phase === "buffering") {
+ return (
+ <p className={`player-phase${compact ? " player-phase--compact" : ""}`} role="status">
+ {phase === "loading" ? "Loading track…" : "Buffering from Internet Archive…"}
+ </p>
+ );
+ }
+ if (status && hasTrack) {
+ return (
+ <p className={`hint${compact ? " hint--compact" : ""}`} role="status">
+ {status}
+ </p>
+ );
+ }
+ if (!hasTrack && status) {
+ return <p className="muted">{status}</p>;
+ }
+ return null;
+}
diff --git a/src/components/TrackSearch.tsx b/src/components/TrackSearch.tsx
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useState } from "react";
+
+import type { TrackInfo } from "../hooks/useMyMusicsPlayback";
+
+type Result = Pick<TrackInfo, "id" | "title" | "artist">;
+
+type Props = {
+ onSelect: (id: string) => void;
+ disabled?: boolean;
+};
+
+export function TrackSearch({ onSelect, disabled }: Props) {
+ const [q, setQ] = useState("");
+ const [results, setResults] = useState<Result[]>([]);
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ const trimmed = q.trim();
+ if (trimmed.length < 2) {
+ setResults([]);
+ return;
+ }
+ const t = window.setTimeout(() => {
+ void (async () => {
+ setBusy(true);
+ try {
+ const res = await fetch(
+ `/api/track/search?q=${encodeURIComponent(trimmed)}&limit=15`,
+ );
+ const body = (await res.json()) as { tracks?: Result[] };
+ setResults(body.tracks ?? []);
+ } catch {
+ setResults([]);
+ } finally {
+ setBusy(false);
+ }
+ })();
+ }, 300);
+ return () => window.clearTimeout(t);
+ }, [q]);
+
+ const pick = useCallback(
+ (id: string) => {
+ onSelect(id);
+ setQ("");
+ setResults([]);
+ },
+ [onSelect],
+ );
+
+ return (
+ <section className="track-search card" aria-label="Search tracks">
+ <h2>Search</h2>
+ <input
+ type="search"
+ className="track-search-input"
+ placeholder="Artist or title…"
+ value={q}
+ onChange={(e) => setQ(e.target.value)}
+ disabled={disabled}
+ autoComplete="off"
+ />
+ {busy ? <p className="muted track-search-hint">Searching…</p> : null}
+ {results.length > 0 ? (
+ <ul className="track-search-results">
+ {results.map((r) => (
+ <li key={r.id}>
+ <button type="button" className="track-search-hit" onClick={() => pick(r.id)}>
+ <span className="h-artist">{r.artist}</span>
+ <span className="sep">—</span>
+ <span className="h-title">{r.title}</span>
+ </button>
+ </li>
+ ))}
+ </ul>
+ ) : q.trim().length >= 2 && !busy ? (
+ <p className="muted track-search-hint">No matches.</p>
+ ) : null}
+ </section>
+ );
+}
diff --git a/src/hooks/useEmbedMessaging.ts b/src/hooks/useEmbedMessaging.ts
@@ -0,0 +1,84 @@
+import { useCallback, useEffect } from "react";
+
+import type { TrackInfo } from "./useMyMusicsPlayback";
+
+export type EmbedPlaybackState = "playing" | "paused" | "buffering" | "error";
+
+const PARENT_ORIGIN =
+ typeof import.meta.env.VITE_EMBED_PARENT_ORIGIN === "string" &&
+ import.meta.env.VITE_EMBED_PARENT_ORIGIN.trim()
+ ? import.meta.env.VITE_EMBED_PARENT_ORIGIN.trim()
+ : "*";
+
+function post(type: string, payload: Record<string, unknown> = {}) {
+ if (typeof window === "undefined" || window.parent === window) return;
+ window.parent.postMessage({ source: "mymusics", type, ...payload }, PARENT_ORIGIN);
+}
+
+type Options = {
+ enabled: boolean;
+ trackCount: number | null;
+ track: TrackInfo | null;
+ streamUrl: string | null;
+ playbackState: EmbedPlaybackState;
+ onNext: () => void;
+ onPlay: () => void;
+ onPause: () => void;
+};
+
+export function useEmbedMessaging({
+ enabled,
+ trackCount,
+ track,
+ streamUrl,
+ playbackState,
+ onNext,
+ onPlay,
+ onPause,
+}: Options) {
+ useEffect(() => {
+ if (!enabled) return;
+ post("mymusics:ready", { trackCount });
+ }, [enabled, trackCount]);
+
+ useEffect(() => {
+ if (!enabled || !track) return;
+ post("mymusics:track", {
+ id: track.id,
+ title: track.title,
+ artist: track.artist,
+ streamUrl,
+ });
+ }, [enabled, track, streamUrl]);
+
+ useEffect(() => {
+ if (!enabled) return;
+ post("mymusics:state", { state: playbackState });
+ }, [enabled, playbackState]);
+
+ const postError = useCallback(
+ (code: string, message: string) => {
+ if (!enabled) return;
+ post("mymusics:error", { code, message });
+ },
+ [enabled],
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+ const onMessage = (ev: MessageEvent) => {
+ const data = ev.data as { source?: string; type?: string; command?: string };
+ if (data?.source !== "mymusics-host") return;
+ if (PARENT_ORIGIN !== "*" && ev.origin !== PARENT_ORIGIN) return;
+ if (data.type !== "mymusics:command") return;
+ const cmd = data.command;
+ if (cmd === "play") onPlay();
+ else if (cmd === "pause") onPause();
+ else if (cmd === "next") onNext();
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, [enabled, onNext, onPlay, onPause]);
+
+ return { postError };
+}
diff --git a/src/hooks/useMyMusicsPlayback.ts b/src/hooks/useMyMusicsPlayback.ts
@@ -1,18 +1,30 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import { loadStoredVolume, saveVolume } from "../lib/playerStorage";
+import { reportEvent } from "../lib/reportEvent";
+import type { EmbedPlaybackState } from "./useEmbedMessaging";
+
export type TrackInfo = {
id: string;
title: string;
artist: string;
};
+export type QueuedTrack = TrackInfo & { streamUrl: string };
+
+export type PlaybackPhase =
+ | "idle"
+ | "loading"
+ | "buffering"
+ | "playing"
+ | "paused"
+ | "error";
+
type RandomResponse = {
track: TrackInfo;
streamUrl: string;
};
-export type QueuedTrack = TrackInfo & { streamUrl: string };
-
type ErrBody = { error?: string };
type HealthBody = {
@@ -25,15 +37,42 @@ type HealthBody = {
const MAX_ARCHIVE_STREAM_ERRORS = 3;
-export function useMyMusicsPlayback() {
+export type PlaybackOptions = {
+ /** Initial track id from URL ?track= or embed ?start= */
+ startTrackId?: string | null;
+ /** Mount with random/up-next (default true) */
+ autoplayOnMount?: boolean;
+ /** Auto-advance when track ends */
+ autoAdvance?: boolean;
+ /** Start muted (embed) */
+ startMuted?: boolean;
+ /** Callback when track/stream changes (embed messaging) */
+ onTrackChange?: (track: TrackInfo | null, streamUrl: string | null) => void;
+};
+
+export function useMyMusicsPlayback(options: PlaybackOptions = {}) {
+ const {
+ startTrackId = null,
+ autoplayOnMount = true,
+ autoAdvance: autoAdvanceInitial = true,
+ startMuted = false,
+ onTrackChange,
+ } = options;
+
const audioRef = useRef<HTMLAudioElement>(null);
+ const preloadAudioRef = useRef<HTMLAudioElement>(null);
const archiveStreamErrorsRef = useRef(0);
const upNextRef = useRef<QueuedTrack | null>(null);
+ const advanceStartedAtRef = useRef<number | null>(null);
+ const reportedPlayRef = useRef(false);
+
const [track, setTrack] = useState<TrackInfo | null>(null);
+ const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [upNext, setUpNext] = useState<QueuedTrack | null>(null);
const [status, setStatus] = useState<string>("");
+ const [playbackPhase, setPlaybackPhase] = useState<PlaybackPhase>("idle");
const [history, setHistory] = useState<TrackInfo[]>([]);
- const [autoPlay, setAutoPlay] = useState(true);
+ const [autoPlay, setAutoPlay] = useState(autoAdvanceInitial);
const [healthWarn, setHealthWarn] = useState<string | null>(null);
const [poolTrackCount, setPoolTrackCount] = useState<number | null>(null);
const [queueBusy, setQueueBusy] = useState(false);
@@ -42,12 +81,36 @@ export function useMyMusicsPlayback() {
upNextRef.current = upNext;
}, [upNext]);
+ useEffect(() => {
+ onTrackChange?.(track, streamUrl);
+ }, [track, streamUrl, onTrackChange]);
+
+ const applyTrack = useCallback(
+ (info: TrackInfo, url: string, addHistory = true) => {
+ setTrack(info);
+ setStreamUrl(url);
+ if (addHistory) setHistory((h) => [info, ...h].slice(0, 15));
+ },
+ [],
+ );
+
const playUrl = useCallback((url: string) => {
const a = audioRef.current;
if (!a) return;
+ setPlaybackPhase("buffering");
a.src = url;
a.load();
- void a.play().catch(() => {});
+ void a.play().catch(() => {
+ setPlaybackPhase("error");
+ });
+ }, []);
+
+ const preloadUrl = useCallback((url: string | null) => {
+ const pre = preloadAudioRef.current;
+ if (!pre || !url) return;
+ if (pre.src === url) return;
+ pre.src = url;
+ pre.load();
}, []);
const refillUpNext = useCallback(async (excludeId: string) => {
@@ -62,21 +125,34 @@ export function useMyMusicsPlayback() {
return;
}
const data = body as RandomResponse;
- setUpNext({
+ const queued: QueuedTrack = {
id: data.track.id,
title: data.track.title,
artist: data.track.artist,
streamUrl: data.streamUrl,
- });
+ };
+ setUpNext(queued);
+ preloadUrl(data.streamUrl);
} catch {
setUpNext(null);
} finally {
setQueueBusy(false);
}
+ }, [preloadUrl]);
+
+ const fetchTrackById = useCallback(async (id: string): Promise<RandomResponse | null> => {
+ const res = await fetch(`/api/track/${encodeURIComponent(id)}`);
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) return null;
+ return body as RandomResponse;
}, []);
const advance = useCallback(async () => {
- setStatus("Loading…");
+ setStatus("");
+ setPlaybackPhase("loading");
+ advanceStartedAtRef.current = Date.now();
+ reportedPlayRef.current = false;
+
const queued = upNextRef.current;
try {
if (queued) {
@@ -85,11 +161,9 @@ export function useMyMusicsPlayback() {
title: queued.title,
artist: queued.artist,
};
- setTrack(info);
- setHistory((h) => [info, ...h].slice(0, 15));
+ applyTrack(info, queued.streamUrl);
playUrl(queued.streamUrl);
setUpNext(null);
- setStatus("");
await refillUpNext(queued.id);
return;
}
@@ -98,7 +172,9 @@ export function useMyMusicsPlayback() {
const body = (await res.json()) as RandomResponse | ErrBody;
if (!res.ok) {
setTrack(null);
+ setStreamUrl(null);
setUpNext(null);
+ setPlaybackPhase("error");
setStatus(
"error" in body && body.error
? body.error
@@ -112,24 +188,66 @@ export function useMyMusicsPlayback() {
title: data.track.title,
artist: data.track.artist,
};
- setTrack(info);
- setHistory((h) => [info, ...h].slice(0, 15));
+ applyTrack(info, data.streamUrl);
playUrl(data.streamUrl);
- setStatus("");
await refillUpNext(info.id);
} catch {
+ setPlaybackPhase("error");
setStatus("Network error while requesting a track.");
setUpNext(null);
}
- }, [playUrl, refillUpNext]);
+ }, [applyTrack, playUrl, refillUpNext]);
+
+ const loadTrackById = useCallback(
+ async (id: string) => {
+ setPlaybackPhase("loading");
+ advanceStartedAtRef.current = Date.now();
+ reportedPlayRef.current = false;
+ setStatus("");
+ try {
+ const data = await fetchTrackById(id);
+ if (!data) {
+ setPlaybackPhase("error");
+ setStatus("Track not found.");
+ return;
+ }
+ const info: TrackInfo = {
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ };
+ applyTrack(info, data.streamUrl);
+ playUrl(data.streamUrl);
+ await refillUpNext(info.id);
+ } catch {
+ setPlaybackPhase("error");
+ setStatus("Network error while loading track.");
+ }
+ },
+ [applyTrack, fetchTrackById, playUrl, refillUpNext],
+ );
const handleAudioPlaying = useCallback(() => {
archiveStreamErrorsRef.current = 0;
setStatus("");
- }, []);
+ setPlaybackPhase("playing");
+ const started = advanceStartedAtRef.current;
+ if (started !== null && !reportedPlayRef.current && track) {
+ reportedPlayRef.current = true;
+ reportEvent({
+ type: "time_to_play",
+ trackId: track.id,
+ ms: Date.now() - started,
+ });
+ }
+ }, [track]);
const handleAudioError = useCallback(() => {
archiveStreamErrorsRef.current += 1;
+ setPlaybackPhase("error");
+ if (track) {
+ reportEvent({ type: "stream_error", trackId: track.id, detail: "audio_element_error" });
+ }
const n = archiveStreamErrorsRef.current;
if (n >= MAX_ARCHIVE_STREAM_ERRORS) {
setStatus(
@@ -139,13 +257,28 @@ export function useMyMusicsPlayback() {
}
setStatus("This track is not available from the Archive right now; trying another…");
if (autoPlay) void advance();
- }, [autoPlay, advance]);
+ }, [autoPlay, advance, track]);
const requestNextTrack = useCallback(() => {
archiveStreamErrorsRef.current = 0;
void advance();
}, [advance]);
+ const handleAudioPause = useCallback(() => {
+ if (audioRef.current?.paused) setPlaybackPhase("paused");
+ }, []);
+
+ const handlePause = useCallback(() => {
+ audioRef.current?.pause();
+ setPlaybackPhase("paused");
+ }, []);
+
+ const handlePlay = useCallback(() => {
+ const a = audioRef.current;
+ if (!a) return;
+ void a.play().catch(() => {});
+ }, []);
+
useEffect(() => {
void (async () => {
try {
@@ -173,14 +306,26 @@ export function useMyMusicsPlayback() {
})();
}, []);
- useEffect(
- () => {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- mount bootstrap via advance()
- void advance();
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only bootstrap
- [],
- );
+ useEffect(() => {
+ const a = audioRef.current;
+ if (!a) return;
+ const vol = loadStoredVolume();
+ if (vol !== null) a.volume = vol;
+ if (startMuted) a.muted = true;
+ const onVol = () => saveVolume(a.volume);
+ a.addEventListener("volumechange", onVol);
+ return () => a.removeEventListener("volumechange", onVol);
+ }, [startMuted]);
+
+ useEffect(() => {
+ if (!autoplayOnMount) return;
+ if (startTrackId) {
+ void loadTrackById(startTrackId);
+ return;
+ }
+ void advance();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount bootstrap
+ }, []);
const onEnded = useCallback(() => {
if (autoPlay) void advance();
@@ -189,11 +334,26 @@ export function useMyMusicsPlayback() {
const showUpNextHint =
poolTrackCount === 1 && track && upNext && upNext.id === track.id;
+ const embedPlaybackState: EmbedPlaybackState =
+ playbackPhase === "playing"
+ ? "playing"
+ : playbackPhase === "paused"
+ ? "paused"
+ : playbackPhase === "buffering" || playbackPhase === "loading"
+ ? "buffering"
+ : playbackPhase === "error"
+ ? "error"
+ : "paused";
+
return {
audioRef,
+ preloadAudioRef,
track,
+ streamUrl,
upNext,
status,
+ playbackPhase,
+ embedPlaybackState,
history,
autoPlay,
setAutoPlay,
@@ -201,8 +361,12 @@ export function useMyMusicsPlayback() {
poolTrackCount,
queueBusy,
requestNextTrack,
+ loadTrackById,
handleAudioPlaying,
handleAudioError,
+ handlePlay,
+ handlePause,
+ handleAudioPause,
onEnded,
showUpNextHint,
};
diff --git a/src/hooks/usePlayerKeyboard.ts b/src/hooks/usePlayerKeyboard.ts
@@ -0,0 +1,32 @@
+import { useEffect, type RefObject } from "react";
+
+type Options = {
+ audioRef: RefObject<HTMLAudioElement | null>;
+ enabled: boolean;
+ onNext: () => void;
+};
+
+export function usePlayerKeyboard({ audioRef, enabled, onNext }: Options) {
+ useEffect(() => {
+ if (!enabled) return;
+ const onKey = (e: KeyboardEvent) => {
+ const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
+ const a = audioRef.current;
+ if (!a) return;
+ if (e.code === "Space") {
+ e.preventDefault();
+ if (a.paused) void a.play().catch(() => {});
+ else a.pause();
+ } else if (e.key === "n" || e.key === "N") {
+ e.preventDefault();
+ onNext();
+ } else if (e.key === "m" || e.key === "M") {
+ e.preventDefault();
+ a.muted = !a.muted;
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [audioRef, enabled, onNext]);
+}
diff --git a/src/lib/embedParams.ts b/src/lib/embedParams.ts
@@ -0,0 +1,37 @@
+export type EmbedTheme = "default" | "compact";
+
+export type EmbedParams = {
+ autoplay: boolean;
+ theme: EmbedTheme;
+ startId: string | null;
+ showBrand: boolean;
+ startMuted: boolean;
+};
+
+function parseBool(raw: string | null, defaultValue: boolean): boolean {
+ if (raw === null || raw === "") return defaultValue;
+ return raw === "1" || raw === "true" || raw === "yes";
+}
+
+export function parseEmbedParams(search: string): EmbedParams {
+ const p = new URLSearchParams(search);
+ const themeRaw = p.get("theme")?.trim().toLowerCase();
+ return {
+ autoplay: parseBool(p.get("autoplay"), true),
+ theme: themeRaw === "compact" ? "compact" : "default",
+ startId: p.get("start")?.trim() || null,
+ showBrand: parseBool(p.get("brand"), true),
+ startMuted: parseBool(p.get("muted"), false),
+ };
+}
+
+export function buildEmbedSearchParams(opts: Partial<EmbedParams>): string {
+ const p = new URLSearchParams();
+ if (opts.autoplay === false) p.set("autoplay", "0");
+ if (opts.theme === "compact") p.set("theme", "compact");
+ if (opts.startId) p.set("start", opts.startId);
+ if (opts.showBrand === false) p.set("brand", "0");
+ if (opts.startMuted) p.set("muted", "1");
+ const s = p.toString();
+ return s ? `?${s}` : "";
+}
diff --git a/src/lib/playerStorage.ts b/src/lib/playerStorage.ts
@@ -0,0 +1,21 @@
+const VOLUME_KEY = "mymusics:volume";
+
+export function loadStoredVolume(): number | null {
+ try {
+ const raw = localStorage.getItem(VOLUME_KEY);
+ if (raw === null) return null;
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 0 || n > 1) return null;
+ return n;
+ } catch {
+ return null;
+ }
+}
+
+export function saveVolume(value: number): void {
+ try {
+ localStorage.setItem(VOLUME_KEY, String(Math.min(1, Math.max(0, value))));
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/src/lib/reportEvent.ts b/src/lib/reportEvent.ts
@@ -0,0 +1,14 @@
+export type ClientEventType = "stream_error" | "time_to_play";
+
+export function reportEvent(payload: {
+ type: ClientEventType;
+ trackId?: string;
+ detail?: string;
+ ms?: number;
+}): void {
+ void fetch("/api/events", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }).catch(() => {});
+}
diff --git a/src/pages/Embed.tsx b/src/pages/Embed.tsx
@@ -1,12 +1,21 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
+import { useLocation } from "react-router-dom";
+
import { CozyAudioBar } from "../components/CozyAudioBar";
+import { PlayerAttribution } from "../components/PlayerAttribution";
+import { PlayerStatus } from "../components/PlayerStatus";
import { PUBLIC_SITE_URL } from "../config/siteUrl";
+import { parseEmbedParams } from "../lib/embedParams";
+import { useEmbedMessaging } from "../hooks/useEmbedMessaging";
import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
import "../App.css";
const EMBED_ROOT_CLASS = "embed-active";
export default function Embed() {
+ const location = useLocation();
+ const params = useMemo(() => parseEmbedParams(location.search), [location.search]);
+
useEffect(() => {
document.documentElement.classList.add(EMBED_ROOT_CLASS);
return () => {
@@ -16,28 +25,55 @@ export default function Embed() {
const {
audioRef,
+ preloadAudioRef,
track,
+ streamUrl,
upNext,
status,
+ playbackPhase,
+ embedPlaybackState,
autoPlay,
setAutoPlay,
healthWarn,
+ poolTrackCount,
queueBusy,
requestNextTrack,
handleAudioPlaying,
handleAudioError,
+ handleAudioPause,
+ handlePlay,
+ handlePause,
onEnded,
showUpNextHint,
- } = useMyMusicsPlayback();
+ } = useMyMusicsPlayback({
+ startTrackId: params.startId,
+ autoplayOnMount: params.autoplay,
+ autoAdvance: params.autoplay,
+ startMuted: params.startMuted,
+ });
+
+ useEmbedMessaging({
+ enabled: true,
+ trackCount: poolTrackCount,
+ track,
+ streamUrl,
+ playbackState: embedPlaybackState,
+ onNext: requestNextTrack,
+ onPlay: handlePlay,
+ onPause: handlePause,
+ });
+
+ const shellClass =
+ params.theme === "compact" ? "embed-shell embed-shell--compact" : "embed-shell";
return (
<div className="embed-page">
- <div className="embed-shell">
+ <div className={shellClass}>
{healthWarn ? (
- <div className="health-banner health-banner--embed" role="alert">
- <strong>Server metadata</strong>
+ <details className="health-banner health-banner--embed">
+ <summary>Server metadata</summary>
<p>{healthWarn}</p>
- </div>
+ </details>
) : null}
<article className="card now-playing">
@@ -63,7 +99,7 @@ export default function Embed() {
<span className="up-next-title">{upNext.title}</span>
</p>
{showUpNextHint ? (
- <p className="up-next-note muted">Only one track in the pool — it will repeat.</p>
+ <p className="up-next-note muted">Only one track — repeats.</p>
) : null}
</>
) : queueBusy ? (
@@ -84,45 +120,52 @@ export default function Embed() {
aria-hidden="true"
onEnded={onEnded}
onPlaying={handleAudioPlaying}
+ onPause={handleAudioPause}
onError={handleAudioError}
/>
+ <audio ref={preloadAudioRef} className="audio-hidden" preload="auto" aria-hidden="true" />
<CozyAudioBar audioRef={audioRef} disabled={!track} />
+ <PlayerStatus phase={playbackPhase} status={status} hasTrack={!!track} compact />
<div className="actions">
<button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
Next
</button>
- <label className="check">
- <input
- type="checkbox"
- checked={autoPlay}
- onChange={(e) => setAutoPlay(e.target.checked)}
- />
- Auto-advance when track ends
- </label>
+ {params.autoplay ? (
+ <label className="check">
+ <input
+ type="checkbox"
+ checked={autoPlay}
+ onChange={(e) => setAutoPlay(e.target.checked)}
+ />
+ Auto-advance
+ </label>
+ ) : null}
</div>
- {status && track ? <p className="hint">{status}</p> : null}
</div>
+ <PlayerAttribution compact />
</article>
- <div className="embed-brand">
- <a
- className="embed-brand-link"
- href={PUBLIC_SITE_URL}
- target="_blank"
- rel="noopener noreferrer"
- title="MyMusics"
- >
- <img
- className="embed-brand-logo"
- src="/mymusics.png"
- alt="MyMusics"
- width={200}
- height={80}
- decoding="async"
- />
- </a>
- </div>
+ {params.showBrand ? (
+ <div className="embed-brand">
+ <a
+ className="embed-brand-link"
+ href={PUBLIC_SITE_URL}
+ target="_blank"
+ rel="noopener noreferrer"
+ title="MyMusics"
+ >
+ <img
+ className="embed-brand-logo"
+ src="/mymusics.png"
+ alt="MyMusics"
+ width={200}
+ height={80}
+ decoding="async"
+ />
+ </a>
+ </div>
+ ) : null}
</div>
</div>
);
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
@@ -1,26 +1,59 @@
+import { useCallback, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+
import { CozyAudioBar } from "../components/CozyAudioBar";
import { EmbedSnippet } from "../components/EmbedSnippet";
+import { PlayerAttribution } from "../components/PlayerAttribution";
+import { PlayerStatus } from "../components/PlayerStatus";
import { SiteHeader } from "../components/SiteHeader";
+import { TrackSearch } from "../components/TrackSearch";
+import { PUBLIC_SITE_URL } from "../config/siteUrl";
import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
+import { usePlayerKeyboard } from "../hooks/usePlayerKeyboard";
import "../App.css";
export default function Home() {
+ const [searchParams] = useSearchParams();
+ const startTrackId = searchParams.get("track")?.trim() || null;
+ const [linkCopied, setLinkCopied] = useState(false);
+
const {
audioRef,
+ preloadAudioRef,
track,
- upNext,
status,
+ playbackPhase,
+ upNext,
history,
autoPlay,
setAutoPlay,
healthWarn,
queueBusy,
requestNextTrack,
+ loadTrackById,
handleAudioPlaying,
handleAudioError,
+ handleAudioPause,
onEnded,
showUpNextHint,
- } = useMyMusicsPlayback();
+ } = useMyMusicsPlayback({
+ startTrackId,
+ autoplayOnMount: true,
+ });
+
+ usePlayerKeyboard({ audioRef, enabled: true, onNext: requestNextTrack });
+
+ const copyShareLink = useCallback(async () => {
+ if (!track) return;
+ const url = `${PUBLIC_SITE_URL}/?track=${encodeURIComponent(track.id)}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ setLinkCopied(true);
+ window.setTimeout(() => setLinkCopied(false), 2000);
+ } catch {
+ setLinkCopied(false);
+ }
+ }, [track]);
return (
<div className="page">
@@ -30,17 +63,23 @@ export default function Home() {
<p>{healthWarn}</p>
<p className="health-banner-hint">
On the host, run <code>curl -sS http://127.0.0.1:38471/api/health</code> (adjust
- port) and fix <code>METADATA_TSV</code> or remove it to use the default{" "}
- <code>data/metadata.tsv</code>.
+ port) and fix <code>METADATA_TSV</code> or run <code>npm run index-metadata</code>.
</p>
</div>
) : null}
<SiteHeader nav="home" />
<main className="main">
+ <TrackSearch onSelect={(id) => void loadTrackById(id)} disabled={!!healthWarn} />
+
<article className="card now-playing">
<header className="card-head">
<h2>Now playing</h2>
+ {track ? (
+ <button type="button" className="btn btn-share" onClick={() => void copyShareLink()}>
+ {linkCopied ? "Link copied!" : "Copy link"}
+ </button>
+ ) : null}
</header>
{track ? (
<div className="track-block">
@@ -82,9 +121,12 @@ export default function Home() {
aria-hidden="true"
onEnded={onEnded}
onPlaying={handleAudioPlaying}
+ onPause={handleAudioPause}
onError={handleAudioError}
/>
+ <audio ref={preloadAudioRef} className="audio-hidden" preload="auto" aria-hidden="true" />
<CozyAudioBar audioRef={audioRef} disabled={!track} />
+ <PlayerStatus phase={playbackPhase} status={status} hasTrack={!!track} />
<div className="actions">
<button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
@@ -99,8 +141,11 @@ export default function Home() {
Auto-advance when track ends
</label>
</div>
- {status && track ? <p className="hint">{status}</p> : null}
+ <p className="player-keys-hint muted">
+ Shortcuts: Space play/pause, N next, M mute
+ </p>
</div>
+ <PlayerAttribution />
</article>
<aside className="card history" aria-label="Recently played">
@@ -108,9 +153,15 @@ export default function Home() {
<ol className="history-list">
{history.map((t, idx) => (
<li key={`${t.id}-${idx}-${t.title}`}>
- <span className="h-artist">{t.artist}</span>
- <span className="sep">—</span>
- <span className="h-title">{t.title}</span>
+ <button
+ type="button"
+ className="history-hit"
+ onClick={() => void loadTrackById(t.id)}
+ >
+ <span className="h-artist">{t.artist}</span>
+ <span className="sep">—</span>
+ <span className="h-title">{t.title}</span>
+ </button>
</li>
))}
</ol>
diff --git a/src/pages/TrackRedirect.tsx b/src/pages/TrackRedirect.tsx
@@ -0,0 +1,11 @@
+import { Navigate, useParams } from "react-router-dom";
+
+export default function TrackRedirect() {
+ const { id } = useParams<{ id: string }>();
+ return (
+ <Navigate
+ to={{ pathname: "/", search: id ? `?track=${encodeURIComponent(id)}` : "" }}
+ replace
+ />
+ );
+}
diff --git a/tsconfig.server.json b/tsconfig.server.json
@@ -14,5 +14,5 @@
"noUnusedParameters": true,
"sourceMap": true
},
- "include": ["server/**/*.ts", "config/ports.ts"]
+ "include": ["server/**/*.ts", "config/ports.ts", "scripts/index-metadata.ts", "scripts/verify-tracks.ts", "scripts/sample-metadata.ts"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["server/**/*.test.ts"],
+ },
+});