mr-magic-mcp-server 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -10,8 +10,8 @@
10
10
  # OAuth client_credentials endpoint at runtime and refreshes the token in
11
11
  # memory automatically. No disk, no scripts, no manual token copying needed.
12
12
  #
13
- # Fallback token (env var) — static bearer token, no auto-refresh
14
- # Set GENIUS_ACCESS_TOKEN. Works everywhere but the token must be
13
+ # Direct token (env var) — static bearer token, no auto-refresh
14
+ # Set GENIUS_DIRECT_TOKEN. Works everywhere but the token must be
15
15
  # manually updated on expiry (or redeploy with a new value).
16
16
  # Use this only when client_credentials are unavailable.
17
17
  #
@@ -23,28 +23,51 @@
23
23
  # ═══════════════════════════════════════════════════════════════════════════════
24
24
  GENIUS_CLIENT_ID= # Auto-refresh: OAuth client ID (enables runtime auto-refresh)
25
25
  GENIUS_CLIENT_SECRET= # Auto-refresh: OAuth client secret (enables runtime auto-refresh)
26
- GENIUS_ACCESS_TOKEN= # Fallback token: static bearer token (required if not using client credentials)
26
+ GENIUS_DIRECT_TOKEN= # Direct token: static bearer token (used when client credentials are unavailable)
27
27
 
28
28
  # ═══════════════════════════════════════════════════════════════════════════════
29
29
  # REQUIRED (feature-specific) — Musixmatch
30
30
  # At least one token source is needed for Musixmatch lyrics support.
31
31
  # WARNING: Unauthorized Public API usage can result in a ban of your account!
32
32
  # ───────────────────────────────────────────────────────────────────────────────
33
- # There are two ways to supply the Musixmatch token:
33
+ # Token resolution priority (1 = highest):
34
34
  #
35
- # Fallback token (env var) — recommended for production / ephemeral hosts
36
- # Set MUSIXMATCH_FALLBACK_TOKEN or MUSIXMATCH_ALT_FALLBACK_TOKEN as an environment variable.
37
- # Use this when persistent filesystem storage is not available (e.g. Render
38
- # free tier, serverless, containers without a mounted volume).
39
- # Resolution order: MUSIXMATCH_FALLBACK_TOKEN (1st) → MUSIXMATCH_ALT_FALLBACK_TOKEN (2nd)
35
+ # 1. MUSIXMATCH_DIRECT_TOKEN (env var)
36
+ # Static bearer token. Works everywhere but must be manually updated on
37
+ # expiry. Use when no other option is available.
40
38
  #
41
- # Cache token (on-disk)local development only
42
- # Run `npm run fetch:musixmatch-token` to sign in via Playwright and write
43
- # the token to `.cache/musixmatch-token.json`. The server reads it on startup.
44
- # Not suitable for ephemeral hosts where the filesystem is wiped on restart.
39
+ # 2. KV storeUpstash Redis or Cloudflare KV (recommended for ephemeral / npx)
40
+ # Run `npm run fetch:musixmatch-token` once to sign in and push the token
41
+ # to KV automatically. The server reads it from KV on startup.
42
+ # Works for npx installs and any deployment without a local filesystem.
43
+ # Configure with UPSTASH_REDIS_REST_URL/TOKEN (already used by the export
44
+ # backend) or the Cloudflare KV vars below.
45
+ #
46
+ # 3. On-disk cache token (local dev / persistent servers only)
47
+ # Run `npm run fetch:musixmatch-token` — writes .cache/musixmatch-token.json.
48
+ # The server reads it on startup when a writable filesystem is available.
45
49
  # ═══════════════════════════════════════════════════════════════════════════════
46
- MUSIXMATCH_FALLBACK_TOKEN= # Fallback token (1st priority) — set this in production
47
- MUSIXMATCH_ALT_FALLBACK_TOKEN= # Fallback token (2nd priority) — alternative env var
50
+ MUSIXMATCH_DIRECT_TOKEN= # Direct token — set this in production / ephemeral deployments (highest priority env var)
51
+
52
+ # KV store for Musixmatch token persistence (ephemeral deployments / npx installs)
53
+ # Priority: Upstash Redis (priority 1) → Cloudflare KV (priority 2)
54
+ # If both are set, Upstash Redis is used and Cloudflare KV is ignored.
55
+ #
56
+ # Upstash Redis: reuse the same creds as the export backend (see below) — no extra setup.
57
+ # Cloudflare KV: set the three CF_* vars; create a namespace at dash.cloudflare.com.
58
+ CF_API_TOKEN= # Cloudflare API token with KV:Edit permission (CF KV only)
59
+ CF_ACCOUNT_ID= # Cloudflare account ID (CF KV only)
60
+ CF_KV_NAMESPACE_ID= # KV namespace ID to store the token in (CF KV only)
61
+ # Optional overrides (defaults are sensible for most users)
62
+ MUSIXMATCH_TOKEN_KV_KEY= # KV key name (default: mr-magic:musixmatch-token)
63
+ MUSIXMATCH_TOKEN_KV_TTL_SECONDS= # Token TTL in seconds (default: 2592000 = 30 days)
64
+
65
+ # Headless / Render deployment — push a pre-captured token without opening a browser.
66
+ # Set MUSIXMATCH_DIRECT_TOKEN to the full musixmatchUserToken JSON payload (from fetch:musixmatch-token output),
67
+ # then run `npm run push:musixmatch-token` or prepend it to the start command:
68
+ # npm run push:musixmatch-token && npm run server:mcp:http
69
+ # If unset, push:musixmatch-token exits silently (safe no-op in start commands).
70
+ # MUSIXMATCH_DIRECT_TOKEN also serves as the runtime token override (highest env priority).
48
71
 
49
72
  # ═══════════════════════════════════════════════════════════════════════════════
50
73
  # REQUIRED (feature-specific) — Airtable
@@ -68,6 +91,8 @@ MR_MAGIC_EXPORT_BACKEND= # local | inline | redis
68
91
  MR_MAGIC_EXPORT_DIR= # Absolute path to the directory to write export files into
69
92
 
70
93
  # Required when BACKEND=redis — get from https://console.upstash.com/redis/rest
94
+ # These same credentials are also used by the Musixmatch KV token store, so
95
+ # setting them once enables both features.
71
96
  UPSTASH_REDIS_REST_URL=
72
97
  UPSTASH_REDIS_REST_TOKEN=
73
98
 
@@ -93,6 +118,9 @@ MELON_COOKIE= # Pin a browser session cookie for consistent result
93
118
  # Advanced / Debug (safe to leave unset in production)
94
119
  # ═══════════════════════════════════════════════════════════════════════════════
95
120
 
121
+ # Playwright Headless mode toggle for fetch scripts that require browser automation (default: false)
122
+ HEADLESS=
123
+
96
124
  # Override project root and .env file path resolution (rarely needed)
97
125
  MR_MAGIC_ROOT=
98
126
  MR_MAGIC_ENV_PATH=
package/README.md CHANGED
@@ -37,28 +37,123 @@ automations, and CLI aficionados can all request lyrics from a single toolchain.
37
37
 
38
38
  ### Quick start — npx (no clone required)
39
39
 
40
- The easiest way to use Mr. Magic in an MCP client is via `npx`. No clone or local
41
- install needed — the package is fetched from npm on first run and cached locally:
40
+ The easiest way to use Mr. Magic is via `npx`. No clone or local install needed —
41
+ the package is fetched from npm on first run and cached locally.
42
+
43
+ #### MCP stdio server (local MCP clients — Cline, Claude Desktop, etc.)
42
44
 
43
45
  ```bash
44
46
  npx -y mr-magic-mcp-server
45
47
  ```
46
48
 
47
- Or install globally so the binaries are always on `PATH`:
49
+ Credentials are passed via the `env` block in your MCP client config (see
50
+ [MCP Client Configuration](#mcp-client-configuration)).
51
+
52
+ #### MCP Streamable HTTP server (remote / browser-based MCP clients)
53
+
54
+ The Streamable HTTP server is the correct choice for TypingMind, any browser-based
55
+ client, or whenever you're hosting Mr. Magic on a remote machine.
48
56
 
49
57
  ```bash
50
- npm install -g mr-magic-mcp-server
58
+ # Streamable HTTP — listens on port 3444, endpoint: /mcp
59
+ GENIUS_DIRECT_TOKEN=your_token \
60
+ MUSIXMATCH_DIRECT_TOKEN=your_token \
61
+ npx -y --package mr-magic-mcp-server mcp-http-server
62
+ ```
63
+
64
+ Connect your client to `http://localhost:3444/mcp` (or your public URL + `/mcp`).
65
+
66
+ The same server also exposes the **legacy SSE** endpoints for older clients:
67
+ - `GET /sse` — opens the event stream
68
+ - `POST /messages?sessionId=...` — sends JSON-RPC messages
69
+
70
+ Both protocols run on the same port simultaneously — no extra config needed.
71
+
72
+ #### JSON HTTP automation server
73
+
74
+ ```bash
75
+ # JSON HTTP — listens on port 3333, endpoint: POST /
76
+ GENIUS_DIRECT_TOKEN=your_token \
77
+ npx -y --package mr-magic-mcp-server http-server
51
78
  ```
52
79
 
53
- When installed globally, start any server directly:
80
+ #### Global install (binaries always on PATH)
54
81
 
55
82
  ```bash
56
- mcp-server # MCP stdio server (recommended for local MCP clients)
57
- mcp-http-server # Streamable HTTP MCP server
83
+ npm install -g mr-magic-mcp-server
84
+
85
+ mcp-server # MCP stdio server
86
+ mcp-http-server # Streamable HTTP MCP server (+ legacy SSE on same port)
58
87
  http-server # JSON HTTP automation server
59
88
  mrmagic-cli --help # CLI
60
89
  ```
61
90
 
91
+ #### Musixmatch token for npx / ephemeral / headless installs
92
+
93
+ When running via `npx`, on Render free tier, or on any server without a browser or
94
+ persistent filesystem, the Musixmatch token cannot be captured via Playwright there.
95
+ The workflow is:
96
+
97
+ 1. **Capture the token locally** (one-time on any machine with a browser):
98
+
99
+ ```bash
100
+ git clone https://github.com/mrnajiboy/mr-magic-mcp-server.git
101
+ cd mr-magic-mcp-server && npm install
102
+ npm run fetch:musixmatch-token
103
+ ```
104
+
105
+ After signing in, the script prints the full token JSON payload.
106
+ Copy the entire printed JSON object (the `MUSIXMATCH_DIRECT_TOKEN=...` line).
107
+
108
+ 2. **Push to KV** so the server can read it on every cold start.
109
+ Set up Upstash Redis (free tier at [console.upstash.com](https://console.upstash.com/redis))
110
+ and run the push script with KV credentials. No browser needed:
111
+
112
+ ```bash
113
+ UPSTASH_REDIS_REST_URL=https://xxx.upstash.io \
114
+ UPSTASH_REDIS_REST_TOKEN=your_upstash_token \
115
+ MUSIXMATCH_DIRECT_TOKEN='<paste token JSON here>' \
116
+ npm run push:musixmatch-token
117
+ ```
118
+
119
+ 3. **Start the server** with the same Upstash credentials — it reads the token from
120
+ KV on every cold start (no `MUSIXMATCH_DIRECT_TOKEN` needed when KV is configured):
121
+
122
+ ```bash
123
+ GENIUS_DIRECT_TOKEN=... \
124
+ UPSTASH_REDIS_REST_URL=https://xxx.upstash.io \
125
+ UPSTASH_REDIS_REST_TOKEN=your_upstash_token \
126
+ npx -y --package mr-magic-mcp-server mcp-http-server
127
+ ```
128
+
129
+ 4. Re-run steps 1–2 only when the Musixmatch token expires (typically ~30 days).
130
+
131
+ #### Musixmatch on Render (headless, no SSH)
132
+
133
+ On Render free tier you cannot SSH in or open a browser. The recommended pattern is:
134
+
135
+ 1. Run `npm run fetch:musixmatch-token` locally, copy the token JSON from the output.
136
+
137
+ 2. In the Render Dashboard → **Environment** tab, set:
138
+ - `MUSIXMATCH_DIRECT_TOKEN` = `<your token JSON>` *(used as both push source and runtime override)*
139
+ - `UPSTASH_REDIS_REST_URL` = your Upstash endpoint
140
+ - `UPSTASH_REDIS_REST_TOKEN` = your Upstash token
141
+
142
+ 3. Set the Render **Start Command** to:
143
+ ```
144
+ npm run push:musixmatch-token && npm run server:mcp:http
145
+ ```
146
+ On every (re)start, the token is pushed to Upstash then the server reads it
147
+ from KV. If `MUSIXMATCH_DIRECT_TOKEN` is unset the push step is a silent no-op.
148
+
149
+ 4. When the token expires: update `MUSIXMATCH_DIRECT_TOKEN` from a fresh local
150
+ `fetch:musixmatch-token` run, trigger a redeploy on Render. Done.
151
+
152
+ > **Genius on ephemeral hosts:** Genius does not need this flow.
153
+ > Set `GENIUS_CLIENT_ID` + `GENIUS_CLIENT_SECRET` instead — the server calls the
154
+ > Genius OAuth `client_credentials` endpoint at runtime, auto-refreshes the token
155
+ > in memory, and never needs a browser, a KV store, or a captured session token.
156
+
62
157
  ### Local repo (development / contribution)
63
158
 
64
159
  1. Clone or download the repository:
@@ -111,23 +206,36 @@ grouped below by purpose.
111
206
  | ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
112
207
  | `GENIUS_CLIENT_ID` | OAuth client ID for auto-refresh (recommended). Get from [genius.com/api-clients](https://genius.com/api-clients). |
113
208
  | `GENIUS_CLIENT_SECRET` | OAuth client secret for auto-refresh (recommended). |
114
- | `GENIUS_ACCESS_TOKEN` | Static fallback bearer token. Used when client credentials are unavailable. |
209
+ | `GENIUS_DIRECT_TOKEN` | Static direct bearer token. Used when client credentials are unavailable. |
115
210
 
116
211
  Token resolution order (first match wins):
117
212
 
118
213
  1. In-memory runtime cache
119
214
  2. Auto-refresh via `GENIUS_CLIENT_ID` + `GENIUS_CLIENT_SECRET` ← **recommended**
120
- 3. `GENIUS_ACCESS_TOKEN` env var (static, no auto-refresh)
121
- 4. On-disk `.cache/genius-token.json` (local dev only)
215
+ 3. `GENIUS_DIRECT_TOKEN` env var (static, no auto-refresh)
216
+ 4. KV store — Upstash Redis or Cloudflare KV (written automatically by auto-refresh)
217
+ 5. On-disk `.cache/genius-token.json` (local dev only)
122
218
 
123
219
  ### Musixmatch credentials
124
220
 
125
- | Variable | Description |
126
- | ------------------------------- | ------------------------------------------------------------------------ |
127
- | `MUSIXMATCH_FALLBACK_TOKEN` | Token env var (1st priority). Use for production / ephemeral hosts. |
128
- | `MUSIXMATCH_ALT_FALLBACK_TOKEN` | Token env var (2nd priority). Alternative name for the same token. |
129
- | `MUSIXMATCH_TOKEN_CACHE` | Path to the on-disk cache file. Default: `.cache/musixmatch-token.json`. |
130
- | `MUSIXMATCH_AUTO_FETCH` | Set to `1` to attempt headless token re-fetch when no token is found. |
221
+ Token resolution order (first match wins):
222
+
223
+ 1. **Env var** `MUSIXMATCH_DIRECT_TOKEN`
224
+ 2. **KV store** Upstash Redis (priority 1) or Cloudflare KV (priority 2)
225
+ 3. **On-disk cache** `.cache/musixmatch-token.json` (local dev / persistent servers)
226
+
227
+ | Variable | Description |
228
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
229
+ | `MUSIXMATCH_DIRECT_TOKEN` | Static bearer token. Recommended for production / ephemeral hosts. Also used as push source by `push:musixmatch-token`. |
230
+ | `UPSTASH_REDIS_REST_URL` | Upstash Redis KV backend URL. Also used by the export backend — set once, used for both. |
231
+ | `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis KV bearer token. Takes precedence over Cloudflare KV when both are set. |
232
+ | `CF_API_TOKEN` | Cloudflare API token with `KV:Edit` permission (Cloudflare KV backend). |
233
+ | `CF_ACCOUNT_ID` | Cloudflare account ID (Cloudflare KV backend). |
234
+ | `CF_KV_NAMESPACE_ID` | Cloudflare KV namespace ID (Cloudflare KV backend). |
235
+ | `MUSIXMATCH_TOKEN_KV_KEY` | KV key name for the token store. Default: `mr-magic:musixmatch-token`. |
236
+ | `MUSIXMATCH_TOKEN_KV_TTL_SECONDS` | Token TTL in the KV store (seconds). Default: `2592000` (30 days). |
237
+ | `MUSIXMATCH_TOKEN_CACHE` | Path to the on-disk cache file. Default: `.cache/musixmatch-token.json`. |
238
+ | `MUSIXMATCH_AUTO_FETCH` | Set to `1` to attempt headless token re-fetch when no token is found. |
131
239
 
132
240
  ### Export and storage
133
241
 
@@ -173,7 +281,7 @@ Genius credentials are resolved in this order — the first available source win
173
281
  refreshed in memory. **Recommended for all deployments**, including Render and
174
282
  ephemeral hosts. No disk, no scripts, no manual token copying.
175
283
 
176
- 2. **Fallback token** (`GENIUS_ACCESS_TOKEN`) — a static bearer token. Works
284
+ 2. **Direct token** (`GENIUS_DIRECT_TOKEN`) — a static bearer token. Works
177
285
  everywhere but does not auto-refresh. Update by redeploying with a new value.
178
286
 
179
287
  3. **Cache token** (`.cache/genius-token.json`) — written by `npm run fetch:genius-token`.
@@ -185,9 +293,8 @@ Musixmatch uses a captured browser session token. There is no OAuth callback.
185
293
 
186
294
  **For production / ephemeral hosts (Render, containers):**
187
295
 
188
- Set `MUSIXMATCH_FALLBACK_TOKEN` (first priority) or `MUSIXMATCH_ALT_FALLBACK_TOKEN`
189
- (second priority) directly in your environment. These are the only reliable options
190
- when the filesystem may be wiped between restarts.
296
+ Set `MUSIXMATCH_DIRECT_TOKEN` directly in your environment. This is the highest-priority
297
+ env var option and the only reliable one when the filesystem may be wiped between restarts.
191
298
 
192
299
  **For local development:**
193
300
 
@@ -205,11 +312,11 @@ The workflow:
205
312
  3. After the redirect to `https://www.musixmatch.com/discover`, the script prints
206
313
  the captured token and writes the cache file.
207
314
  4. **For remote deployments:** copy the `token` value from the printed JSON and set
208
- it as `MUSIXMATCH_FALLBACK_TOKEN` in your platform environment. Do **not** rely on
315
+ it as `MUSIXMATCH_DIRECT_TOKEN` in your platform environment. Do **not** rely on
209
316
  the cache file surviving restarts on ephemeral hosts.
210
317
 
211
318
  > **Developer accounts:** Get API access from [developer.musixmatch.com](https://developer.musixmatch.com)
212
- > and set the resulting token as `MUSIXMATCH_FALLBACK_TOKEN`.
319
+ > and set the resulting token as `MUSIXMATCH_DIRECT_TOKEN`.
213
320
  >
214
321
  > **Public accounts:** Visit [auth.musixmatch.com](https://auth.musixmatch.com), sign in,
215
322
  > and capture the token using the script above.
@@ -309,7 +416,7 @@ Recommended Render service settings:
309
416
 
310
417
  - **Start Command:** `npm run server:mcp:http`
311
418
  - **Environment:** set provider credentials (`GENIUS_CLIENT_ID`, `GENIUS_CLIENT_SECRET`,
312
- `MUSIXMATCH_FALLBACK_TOKEN`, etc.) in the Render Dashboard → Environment tab
419
+ `MUSIXMATCH_DIRECT_TOKEN`, etc.) in the Render Dashboard → Environment tab
313
420
  - **Health Check Path:** `/health` (returns `{ "status": "ok", "providers": [...] }`)
314
421
 
315
422
  > For custom domains, add them to `MR_MAGIC_ALLOWED_HOSTS` (comma-separated) in
@@ -615,8 +722,8 @@ Works with any local MCP client that supports `command` / `args`:
615
722
  "command": "npx",
616
723
  "args": ["-y", "mr-magic-mcp-server"],
617
724
  "env": {
618
- "GENIUS_ACCESS_TOKEN": "...",
619
- "MUSIXMATCH_FALLBACK_TOKEN": "...",
725
+ "GENIUS_DIRECT_TOKEN": "...",
726
+ "MUSIXMATCH_DIRECT_TOKEN": "...",
620
727
  "AIRTABLE_PERSONAL_ACCESS_TOKEN": "..."
621
728
  }
622
729
  }
@@ -1055,8 +1162,8 @@ npm run server:mcp:http # Streamable HTTP MCP — port 3444
1055
1162
 
1056
1163
  - **LRCLIB** — Public API with synced lyric coverage. No auth required.
1057
1164
  - **Genius** — Requires `GENIUS_CLIENT_ID` + `GENIUS_CLIENT_SECRET` (auto-refresh,
1058
- recommended) or `GENIUS_ACCESS_TOKEN` (static fallback token).
1059
- - **Musixmatch** — Requires a token. Use `MUSIXMATCH_FALLBACK_TOKEN` for production /
1165
+ recommended) or `GENIUS_DIRECT_TOKEN` (static direct token).
1166
+ - **Musixmatch** — Requires a token. Use `MUSIXMATCH_DIRECT_TOKEN` for production /
1060
1167
  ephemeral hosts; use the on-disk cache token (`npm run fetch:musixmatch-token`) for
1061
1168
  local dev. See [Musixmatch](#musixmatch) for the full workflow.
1062
1169
  - **Melon** — Works anonymously. Set `MELON_COOKIE` for pinned / reproducible sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-magic-mcp-server",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,7 +30,8 @@
30
30
  "format": "prettier --write .",
31
31
  "format:check": "prettier --check .",
32
32
  "test": "node src/tests/run-tests.js",
33
- "fetch:musixmatch-token": "node src/scripts/fetch_MUSIXMATCH_ALT_FALLBACK_TOKEN.mjs",
33
+ "fetch:musixmatch-token": "node src/scripts/fetch_musixmatch_token.mjs",
34
+ "push:musixmatch-token": "node src/scripts/push_musixmatch_token.mjs",
34
35
  "fetch:genius-token": "node src/scripts/fetch_genius_token.mjs",
35
36
  "repro:mcp:arg-boundary": "node src/scripts/mcp-arg-boundary-repro.mjs",
36
37
  "repro:mcp:arg-boundary:sdk": "node src/scripts/mcp-arg-boundary-sdk-repro.mjs"
@@ -25,7 +25,7 @@ async function ensureGeniusAuth() {
25
25
  await getGeniusToken();
26
26
  return;
27
27
  }
28
- assertEnv(['GENIUS_ACCESS_TOKEN']);
28
+ assertEnv(['GENIUS_DIRECT_TOKEN']);
29
29
  }
30
30
 
31
31
  function normalizeHit(hit, query) {
@@ -138,12 +138,11 @@ async function macroRequest(track) {
138
138
  async function ensureMusixmatchToken() {
139
139
  const token = await getMusixmatchToken();
140
140
  if (!token) {
141
- // Neither a fallback token (MUSIXMATCH_FALLBACK_TOKEN / MUSIXMATCH_ALT_USER_TOKEN env vars) nor a
141
+ // Neither a direct token (MUSIXMATCH_DIRECT_TOKEN env var) nor a KV token nor a
142
142
  // cache token (on-disk .cache/musixmatch-token.json) could be found.
143
143
  throw new Error(
144
144
  'Musixmatch token not found. ' +
145
- 'Set MUSIXMATCH_FALLBACK_TOKEN (fallback token recommended for production/ephemeral hosts) ' +
146
- 'or MUSIXMATCH_ALT_USER_TOKEN as an environment variable, ' +
145
+ 'Set MUSIXMATCH_DIRECT_TOKEN as an environment variable (recommended for production/ephemeral hosts), ' +
147
146
  'or run `npm run fetch:musixmatch-token` to populate the on-disk cache token.'
148
147
  );
149
148
  }
@@ -3,7 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
 
5
5
  import axios from 'axios';
6
- import '../src/utils/config.js';
6
+ import '../utils/config.js';
7
+ import { describeKvBackend, isKvConfigured, kvSet } from '../utils/kv-store.js';
7
8
 
8
9
  const TOKEN_ENDPOINT = 'https://api.genius.com/oauth/token';
9
10
 
@@ -17,10 +18,10 @@ function printDeploymentBlock(accessToken) {
17
18
  console.log('LOCAL DEVELOPMENT (cache token)');
18
19
  console.log(' Token written to the cache file above.');
19
20
  console.log(' The server reads it on startup when a writable filesystem is available.\n');
20
- console.log('RENDER / EPHEMERAL DEPLOYMENTS (fallback token)');
21
+ console.log('RENDER / EPHEMERAL DEPLOYMENTS (direct token)');
21
22
  console.log(' If you cannot use client_credentials, set the token as an env var');
22
- console.log(' in your platform dashboard. It acts as a static fallback token:\n');
23
- console.log(` GENIUS_ACCESS_TOKEN=${accessToken}\n`);
23
+ console.log(' in your platform dashboard. It acts as a static direct token:\n');
24
+ console.log(` GENIUS_DIRECT_TOKEN=${accessToken}\n`);
24
25
  console.log(" Note: static tokens don't auto-refresh. Redeploy with a new token");
25
26
  console.log(' if/when it expires. The client_credentials path avoids this entirely.');
26
27
  console.log('─'.repeat(68) + '\n');
@@ -66,6 +67,22 @@ async function main() {
66
67
  console.log(`\nCache token written to: ${cachePath}`);
67
68
  console.log('(The server reads this file on startup when a writable filesystem is available.)');
68
69
 
70
+ // Write to KV store if configured (ephemeral hosts, npx installs).
71
+ if (isKvConfigured()) {
72
+ const kvKey = process.env.GENIUS_TOKEN_KV_KEY || 'mr-magic:genius-token';
73
+ const kvTtl = parseInt(process.env.GENIUS_TOKEN_KV_TTL_SECONDS || '3600', 10);
74
+ const kvPayload = JSON.stringify({
75
+ access_token: accessToken,
76
+ expires_at: Date.now() + (expiresIn || 3600) * 1000
77
+ });
78
+ try {
79
+ await kvSet(kvKey, kvPayload, kvTtl);
80
+ console.log(`Token written to KV store (${describeKvBackend()}) under key: ${kvKey}`);
81
+ } catch (err) {
82
+ console.warn(`Failed to write token to KV store: ${err.message}`);
83
+ }
84
+ }
85
+
69
86
  printDeploymentBlock(accessToken);
70
87
  } catch (error) {
71
88
  console.error('Failed to refresh Genius token:', error.response?.data || error.message);
@@ -2,8 +2,9 @@
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
 
5
- import { chromium } from 'playwright';
6
- import '../src/utils/config.js';
5
+ import { chromium, firefox, webkit } from 'playwright';
6
+ import '../utils/config.js';
7
+ import { describeKvBackend, isKvConfigured, kvSet } from '../utils/kv-store.js';
7
8
 
8
9
  const AUTH_URL = 'https://auth.musixmatch.com/';
9
10
 
@@ -17,8 +18,21 @@ async function saveToken(token, desktopCookie) {
17
18
  payload.desktopCookie = desktopCookie;
18
19
  }
19
20
  await writeFile(cachePath, JSON.stringify(payload, null, 2), 'utf8');
20
- console.log(`\nCache token written to: ${cachePath}`);
21
- console.log('(The server reads this file on startup when a writable filesystem is available.)');
21
+ console.log(`\nToken written to cache: ${cachePath}`);
22
+ console.log('(Local and persistent servers read this file on startup.)');
23
+ }
24
+
25
+ async function saveToKv(token, desktopCookie) {
26
+ if (!isKvConfigured()) return;
27
+ const kvKey = process.env.MUSIXMATCH_TOKEN_KV_KEY || 'mr-magic:musixmatch-token';
28
+ const kvTtl = parseInt(process.env.MUSIXMATCH_TOKEN_KV_TTL_SECONDS || '2592000', 10);
29
+ const payload = JSON.stringify({ token, ...(desktopCookie ? { desktopCookie } : {}) });
30
+ try {
31
+ await kvSet(kvKey, payload, kvTtl);
32
+ console.log(`Token written to KV store (${describeKvBackend()}) under key: ${kvKey}`);
33
+ } catch (error) {
34
+ console.error(`Failed to write token to KV store: ${error.message}`);
35
+ }
22
36
  }
23
37
 
24
38
  function printDeploymentBlock(tokenValue) {
@@ -26,32 +40,133 @@ function printDeploymentBlock(tokenValue) {
26
40
  typeof tokenValue === 'string'
27
41
  ? tokenValue
28
42
  : (tokenValue?.message?.body?.usertoken ?? JSON.stringify(tokenValue));
43
+ const kvBackend = isKvConfigured() ? describeKvBackend() : null;
44
+
29
45
  console.log('\n' + '─'.repeat(68));
30
46
  console.log('Token captured successfully!\n');
31
- console.log('LOCAL DEVELOPMENT (cache token)');
32
- console.log(' The token has been written to the cache file above.');
33
- console.log(' The server loads it at startup — no further action needed.\n');
34
- console.log('RENDER / EPHEMERAL DEPLOYMENTS (fallback token)');
35
- console.log(' The filesystem is wiped on restart, so set the token as an');
36
- console.log(' environment variable in your platform dashboard instead:\n');
37
- console.log(` MUSIXMATCH_FALLBACK_TOKEN=${tokenString}\n`);
38
- console.log(' The server reads MUSIXMATCH_FALLBACK_TOKEN on startup (1st priority)');
39
- console.log(' and never touches the cache file on ephemeral hosts.');
47
+
48
+ console.log('LOCAL & PERSISTENT SERVERS (cache token)');
49
+ console.log(' Token written to .cache/musixmatch-token.json (or MUSIXMATCH_TOKEN_CACHE).');
50
+ console.log(' Any server with a writable, persistent filesystem (local dev, VPS,');
51
+ console.log(' dedicated host) reads it automatically on startup.');
52
+ console.log(' Re-run this script only when your token expires.\n');
53
+
54
+ if (kvBackend) {
55
+ console.log(`EPHEMERAL / NPX INSTALLS KV STORE (${kvBackend})`);
56
+ console.log(` Token written to KV key "mr-magic:musixmatch-token".`);
57
+ console.log(' The server reads it on startup automatically — no extra config needed.');
58
+ console.log(' Re-run this script when your token expires to refresh the KV entry.\n');
59
+ } else {
60
+ console.log('EPHEMERAL / NPX INSTALLS — KV STORE (not configured)');
61
+ console.log(' Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN (Upstash Redis)');
62
+ console.log(' or CF_API_TOKEN + CF_ACCOUNT_ID + CF_KV_NAMESPACE_ID (Cloudflare KV)');
63
+ console.log(' and re-run this script to have the token stored in KV automatically.\n');
64
+ }
65
+
66
+ console.log('EPHEMERAL / SERVERLESS — MANUAL ENV VAR OVERRIDE');
67
+ console.log(' Copy the token below and set it in your platform dashboard.');
68
+ console.log(' The server reads MUSIXMATCH_DIRECT_TOKEN on startup (highest priority env var):\n');
69
+ console.log(` MUSIXMATCH_DIRECT_TOKEN=${tokenString}\n`);
70
+
40
71
  console.log('─'.repeat(68) + '\n');
41
72
  }
42
73
 
74
+ function isHeadlessEnabled() {
75
+ const value = (process.env.HEADLESS || '').trim().toLowerCase();
76
+ return value === '1' || value === 'true' || value === 'yes';
77
+ }
78
+
43
79
  async function main() {
44
- console.log('Launching Playwright to acquire Musixmatch token...');
45
- const browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' });
46
- const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
80
+ const headless = isHeadlessEnabled();
81
+
82
+ // Persistent browser session stores cookies/logins between script runs so you don't
83
+ // have to sign in again until your session actually expires.
84
+ // Override with PLAYWRIGHT_SESSION_DIR env var if you need a different location.
85
+ const sessionDir =
86
+ process.env.PLAYWRIGHT_SESSION_DIR || path.resolve('.cache', 'playwright-session');
87
+ await mkdir(sessionDir, { recursive: true });
88
+
89
+ console.log(`Launching Playwright (headless=${headless}) to acquire Musixmatch token...`);
90
+ console.log(`Browser session directory: ${sessionDir}\n`);
91
+
92
+ // Try real installed browsers in priority order so Google OAuth doesn't block the
93
+ // automated bundled Chromium. Override with BROWSER=<name> to skip straight to one.
94
+ // Chromium channels : chrome, brave, msedge, comet
95
+ // Other engines : firefox, safari (webkit)
96
+ // Last resort : bundled Chromium (may be blocked by Google OAuth)
97
+ //
98
+ // launchPersistentContext() is used instead of launch() + newContext() so the browser
99
+ // session (cookies, logins) is saved to sessionDir and reused on subsequent runs.
100
+ const CHROMIUM_UA =
101
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
102
+ const chromiumArgs = ['--disable-blink-features=AutomationControlled'];
103
+ const baseOpts = { headless, slowMo: headless ? 0 : 150, viewport: { width: 1280, height: 900 } };
104
+ const chromiumOpts = { ...baseOpts, args: chromiumArgs, userAgent: CHROMIUM_UA };
105
+
106
+ // Each launcher returns a BrowserContext (launchPersistentContext skips browser.newContext()).
107
+ const candidates = [
108
+ ['chrome', () => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts, channel: 'chrome' })],
109
+ ['brave (channel)', () => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts, channel: 'brave' })],
110
+ ['brave (path)', () => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts, executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser' })],
111
+ ['msedge', () => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts, channel: 'msedge' })],
112
+ ['comet', () => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts, executablePath: '/Applications/Comet.app/Contents/MacOS/Comet' })],
113
+ ['firefox', () => firefox.launchPersistentContext(sessionDir, { ...baseOpts })],
114
+ ['safari (webkit)', () => webkit.launchPersistentContext(sessionDir, { ...baseOpts })],
115
+ ['bundled chromium',() => chromium.launchPersistentContext(sessionDir, { ...chromiumOpts })],
116
+ ];
117
+
118
+ // If BROWSER is set, move that candidate to the front.
119
+ const browserEnv = (process.env.BROWSER || '').trim().toLowerCase();
120
+ const orderedCandidates = browserEnv
121
+ ? [
122
+ ...candidates.filter(([label]) => label.startsWith(browserEnv)),
123
+ ...candidates.filter(([label]) => !label.startsWith(browserEnv)),
124
+ ]
125
+ : candidates;
126
+
127
+ let context;
128
+ let chosenLabel;
129
+ for (const [label, launcher] of orderedCandidates) {
130
+ try {
131
+ context = await launcher();
132
+ chosenLabel = label;
133
+ break;
134
+ } catch (err) {
135
+ console.warn(` ${label} not available (${err.message?.split('\n')[0]}), trying next...`);
136
+ }
137
+ }
138
+
139
+ if (!context) {
140
+ console.error('No usable browser found. Install Chrome, Brave, Edge, Firefox, or Safari.');
141
+ process.exit(1);
142
+ }
143
+ console.log(`Using browser: ${chosenLabel}`);
144
+
145
+ // Remove the webdriver flag that Google uses to detect automated browsers.
146
+ await context.addInitScript(() => {
147
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
148
+ });
149
+
47
150
  const page = await context.newPage();
48
151
 
49
- console.log('Navigate to Musixmatch login and sign in.');
50
- await page.goto(AUTH_URL, { waitUntil: 'domcontentloaded' });
51
- console.log('Waiting to be redirected to https://www.musixmatch.com/discover ...');
52
- await page.waitForURL('**/discover', { timeout: 0 });
152
+ page.on('console', (msg) => {
153
+ if (msg.type() === 'error') {
154
+ const text = msg.text();
155
+ // Suppress benign COOP warning emitted by the auth page itself.
156
+ if (text.includes('Cross-Origin-Opener-Policy')) return;
157
+ console.error(`[browser console error] ${text}`);
158
+ }
159
+ });
160
+ page.on('pageerror', (err) => console.error(`[browser page error] ${err.message}`));
161
+
162
+ console.log(`Navigating to ${AUTH_URL} — sign in in the browser window that appears.`);
163
+ // 'commit' fires as soon as the server response starts (before content loads), which avoids
164
+ // ERR_ABORTED on browsers like Comet that intercept or redirect during initial navigation.
165
+ await page.goto(AUTH_URL, { waitUntil: 'commit' });
166
+ console.log('Waiting to be redirected to https://account.musixmatch.com/ ...');
167
+ await page.waitForURL('https://account.musixmatch.com/**', { timeout: 0 });
53
168
 
54
- const cookies = await context.cookies('https://www.musixmatch.com');
169
+ const cookies = await context.cookies('https://account.musixmatch.com');
55
170
  const userCookie = cookies.find((cookie) => cookie.name === 'musixmatchUserToken');
56
171
  const desktopCookie = cookies.find((cookie) => cookie.name === 'web-desktop-app-v1.0');
57
172
  if (!userCookie) {
@@ -70,14 +185,20 @@ async function main() {
70
185
  console.log('\nMusixmatch token payload:');
71
186
  console.log(JSON.stringify(parsed, null, 2));
72
187
 
73
- await saveToken(parsed, desktopCookie ? decodeURIComponent(desktopCookie.value) : null);
188
+ const decodedDesktopCookie = desktopCookie ? decodeURIComponent(desktopCookie.value) : null;
189
+
190
+ // Write to all configured storage backends in parallel.
191
+ await Promise.allSettled([
192
+ saveToken(parsed, decodedDesktopCookie),
193
+ saveToKv(parsed, decodedDesktopCookie),
194
+ ]);
74
195
 
75
196
  // Extract the raw token string for the deployment hint.
76
197
  // The parsed payload is the full musixmatchUserToken JSON object; the server
77
198
  // stores and reads the entire parsed object as the `token` field.
78
199
  printDeploymentBlock(parsed);
79
200
 
80
- await browser.close();
201
+ await context.close();
81
202
  }
82
203
 
83
204
  main().catch((error) => {