mymusics

retro MySpace-style music player
Log | Files | Refs | README

commit 210ba5cbd5ee59aba77c2fa6a3ee74b25d67cb0e
parent 9dec174a2bf61d30704a926b51314913b42a49c6
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Wed, 20 May 2026 21:26:20 -0300

getting better

Diffstat:
M.env.example | 15+++++++++++++++
M.gitignore | 4++++
ADockerfile | 31+++++++++++++++++++++++++++++++
MREADME.md | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Adocker-compose.yml | 13+++++++++++++
Mecosystem.config.cjs | 4+++-
Mindex.html | 6++++++
Mpackage-lock.json | 821++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 11++++++++++-
Ascripts/index-metadata.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/sample-metadata.ts | 25+++++++++++++++++++++++++
Ascripts/verify-tracks.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/index.ts | 330++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Aserver/metadata.test.ts | 32++++++++++++++++++++++++++++++++
Mserver/metadata.ts | 44+++++++++++++++++++++++++-------------------
Aserver/oembed.ts | 35+++++++++++++++++++++++++++++++++++
Aserver/paths.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Aserver/rateLimit.ts | 19+++++++++++++++++++
Aserver/trackStore.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/trackStore.ts | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/App.css | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/App.tsx | 2++
Msrc/components/EmbedSnippet.tsx | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/components/PlayerAttribution.tsx | 15+++++++++++++++
Asrc/components/PlayerStatus.tsx | 29+++++++++++++++++++++++++++++
Asrc/components/TrackSearch.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hooks/useEmbedMessaging.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/hooks/useMyMusicsPlayback.ts | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Asrc/hooks/usePlayerKeyboard.ts | 32++++++++++++++++++++++++++++++++
Asrc/lib/embedParams.ts | 37+++++++++++++++++++++++++++++++++++++
Asrc/lib/playerStorage.ts | 21+++++++++++++++++++++
Asrc/lib/reportEvent.ts | 14++++++++++++++
Msrc/pages/Embed.tsx | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/pages/Home.tsx | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Asrc/pages/TrackRedirect.tsx | 11+++++++++++
Mtsconfig.server.json | 2+-
Avitest.config.ts | 7+++++++
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"], + }, +});