mr-magic-mcp-server 0.3.11 → 0.3.12

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
@@ -94,7 +94,7 @@ CF_ACCOUNT_ID= # Cloudflare account ID (CF KV only)
94
94
  CF_KV_NAMESPACE_ID= # KV namespace ID to store the token in (CF KV only)
95
95
 
96
96
  # Required when BACKEND=local
97
- MR_MAGIC_EXPORT_DIR=/Users/naji/Downloads/magic-export/ # Absolute path to the directory to write export files into
97
+ MR_MAGIC_EXPORT_DIR=/Users/yourusername/Downloads/magic-export/ # Absolute path to the directory to write export files into
98
98
 
99
99
  # Base URL the server uses to build download links returned to the MCP client.
100
100
  # Examples: https://yourserver.com | http://localhost:3444 | http://localhost:3333
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-magic-mcp-server",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,18 +61,18 @@
61
61
  "node": ">=20"
62
62
  },
63
63
  "dependencies": {
64
- "@dotenvx/dotenvx": "^1.59.1",
64
+ "@dotenvx/dotenvx": "^1.61.1",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "axios": "^1.14.0",
66
+ "axios": "^1.15.0",
67
67
  "cheerio": "^1.2.0",
68
68
  "commander": "^14.0.3"
69
69
  },
70
70
  "devDependencies": {
71
- "eslint": "^10.1.0",
71
+ "eslint": "^10.2.1",
72
72
  "eslint-config-prettier": "^10.1.8",
73
73
  "eslint-plugin-import-x": "^4.16.2",
74
- "playwright": "^1.59.0",
75
- "prettier": "^3.8.1"
74
+ "playwright": "^1.59.1",
75
+ "prettier": "^3.8.3"
76
76
  },
77
77
  "overrides": {
78
78
  "entities": "^7.0.1",
package/src/index.js CHANGED
@@ -45,6 +45,10 @@ function rankRecord(record) {
45
45
  return contentScore + syncedBonus + confidenceScore;
46
46
  }
47
47
 
48
+ function isBlockedRecord(record) {
49
+ return record?.status === 'captcha_blocked' || record?.status === 'blocked';
50
+ }
51
+
48
52
  async function tryProviders(track, { syncedOnly = false, providerNames = [] } = {}) {
49
53
  const matches = [];
50
54
  let bestSynced = null;
@@ -88,22 +92,23 @@ async function tryProviders(track, { syncedOnly = false, providerNames = [] } =
88
92
 
89
93
  export async function findLyrics(track, options = {}) {
90
94
  const { matches, best } = await tryProviders(track, options);
95
+ const visibleMatches = matches.filter(
96
+ ({ result }) => lyricContentScore(result) > 0 || isBlockedRecord(result)
97
+ );
91
98
  return {
92
- // Filter out candidates with no actual lyric content (empty / unhydrated stubs).
93
- // This keeps the matches list clean for all consumers: CLI, MCP tools, HTTP server.
94
- matches: matches
95
- .filter(({ result }) => lyricContentScore(result) > 0)
96
- .map(({ provider, result }) => ({ provider, result })),
99
+ // Keep lyric-bearing matches plus explicit provider-block states.
100
+ matches: visibleMatches.map(({ provider, result }) => ({ provider, result })),
97
101
  best: best?.result ?? null
98
102
  };
99
103
  }
100
104
 
101
105
  export async function findSyncedLyrics(track, options = {}) {
102
106
  const { matches, best } = await tryProviders(track, { ...options, syncedOnly: true });
107
+ const visibleMatches = matches.filter(
108
+ ({ result }) => lyricContentScore(result) > 0 || isBlockedRecord(result)
109
+ );
103
110
  return {
104
- matches: matches
105
- .filter(({ result }) => lyricContentScore(result) > 0)
106
- .map(({ provider, result }) => ({ provider, result })),
111
+ matches: visibleMatches.map(({ provider, result }) => ({ provider, result })),
107
112
  best: best?.result ?? null
108
113
  };
109
114
  }
@@ -4,6 +4,7 @@ import { normalizeLyricRecord } from '../provider-result-schema.js';
4
4
  import { createLogger } from '../utils/logger.js';
5
5
  import {
6
6
  getMusixmatchToken,
7
+ getMusixmatchDesktopCookie,
7
8
  invalidateMusixmatchToken
8
9
  } from '../utils/tokens/musixmatch-token-manager.js';
9
10
 
@@ -15,9 +16,60 @@ const DEFAULT_HEADERS = {
15
16
  authority: 'apic-desktop.musixmatch.com',
16
17
  'User-Agent': MOZILLA_USER_AGENT
17
18
  };
19
+ const MXM_COOKIE_FALLBACK = 'x-mxm-token-guid=';
18
20
 
19
21
  const logger = createLogger('provider:musixmatch');
20
22
 
23
+ function formatSubtitleTimestamp(item) {
24
+ if (!item || typeof item !== 'object') return null;
25
+
26
+ const text =
27
+ typeof item.text === 'string'
28
+ ? item.text
29
+ : typeof item.subtitle_body === 'string'
30
+ ? item.subtitle_body
31
+ : '';
32
+
33
+ const candidates = [
34
+ item.time,
35
+ item.timestamp,
36
+ item.ts,
37
+ item.line_time,
38
+ item.lineTime,
39
+ item.time_total,
40
+ item.timeTotal
41
+ ].filter((value) => value !== null && value !== undefined);
42
+
43
+ const numericCandidate = candidates.find((value) => typeof value === 'number');
44
+ if (typeof numericCandidate === 'number' && Number.isFinite(numericCandidate)) {
45
+ const totalCentiseconds = Math.max(0, Math.round(numericCandidate * 100));
46
+ const minutes = Math.floor(totalCentiseconds / 6000);
47
+ const seconds = Math.floor((totalCentiseconds % 6000) / 100);
48
+ const hundredths = totalCentiseconds % 100;
49
+ return `[${minutes.toString().padStart(2, '0')}:${seconds
50
+ .toString()
51
+ .padStart(2, '0')}.${hundredths.toString().padStart(2, '0')}] ${text}`.trim();
52
+ }
53
+
54
+ const timeObject = candidates.find((value) => value && typeof value === 'object');
55
+ if (timeObject) {
56
+ const minutes = Number(timeObject.minutes ?? timeObject.min ?? 0);
57
+ const seconds = Number(timeObject.seconds ?? timeObject.sec ?? 0);
58
+ const hundredths = Number(
59
+ timeObject.hundredths ?? timeObject.hundredth ?? timeObject.cs ?? timeObject.milliseconds ?? 0
60
+ );
61
+ if ([minutes, seconds, hundredths].every(Number.isFinite)) {
62
+ const normalizedHundredths =
63
+ hundredths > 99 ? Math.floor(hundredths / 10) : Math.max(0, hundredths);
64
+ return `[${minutes.toString().padStart(2, '0')}:${seconds
65
+ .toString()
66
+ .padStart(2, '0')}.${normalizedHundredths.toString().padStart(2, '0')}] ${text}`.trim();
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
21
73
  function buildParams(track, token) {
22
74
  const durationSeconds = track.duration ? Math.round(track.duration / 1000) : '';
23
75
  return new URLSearchParams({
@@ -36,13 +88,56 @@ function buildParams(track, token) {
36
88
  });
37
89
  }
38
90
 
91
+ function summarizeMacroStatus(body = {}) {
92
+ return {
93
+ matcher: body['matcher.track.get']?.message?.header ?? null,
94
+ lyrics: body['track.lyrics.get']?.message?.header ?? null,
95
+ subtitles: body['track.subtitles.get']?.message?.header ?? null
96
+ };
97
+ }
98
+
99
+ function buildBlockedRecord(track = {}, body = {}, reason = 'captcha') {
100
+ return normalizeLyricRecord({
101
+ provider: 'musixmatch',
102
+ id: null,
103
+ trackName: track.title || null,
104
+ artistName: track.artist || null,
105
+ albumName: track.album || null,
106
+ duration: track.duration ? Math.round(track.duration / 1000) : null,
107
+ plainLyrics: null,
108
+ syncedLyrics: null,
109
+ sourceUrl: null,
110
+ confidence: 0,
111
+ synced: false,
112
+ status: reason === 'captcha' ? 'captcha_blocked' : 'blocked',
113
+ raw: body
114
+ });
115
+ }
116
+
117
+ function classifyUnauthorizedPayload(payload) {
118
+ const hint = payload?.message?.header?.hint;
119
+ if (hint === 'renew') {
120
+ return 'MUSIXMATCH_TOKEN_RENEW';
121
+ }
122
+ return 'MUSIXMATCH_CAPTCHA';
123
+ }
124
+
39
125
  function normalizeBody(body) {
40
126
  const matcher = body['matcher.track.get']?.message?.body;
41
127
  if (!matcher) {
128
+ logger.warn('Musixmatch matcher body missing', { macroStatus: summarizeMacroStatus(body) });
42
129
  return null;
43
130
  }
44
131
 
45
132
  const meta = matcher.track || {};
133
+ if (!meta.track_id) {
134
+ logger.warn('Musixmatch matcher returned no track', {
135
+ macroStatus: summarizeMacroStatus(body),
136
+ matcherBodyType: Array.isArray(matcher) ? 'array' : typeof matcher,
137
+ matcherPreview: Array.isArray(matcher) ? matcher.slice(0, 2) : matcher
138
+ });
139
+ return null;
140
+ }
46
141
  const lyricsBody = body['track.lyrics.get']?.message?.body?.lyrics?.lyrics_body || '';
47
142
  const subtitlesRoot = body['track.subtitles.get']?.message?.body;
48
143
  const subtitleEntry =
@@ -73,18 +168,38 @@ function normalizeBody(body) {
73
168
  }
74
169
  }
75
170
 
76
- const syncedLyrics = syncedLines
77
- .map((item) => {
78
- const time = item?.time || {};
79
- return `[${time.minutes.toString().padStart(2, '0')}:${time.seconds.toString().padStart(2, '0')}.${(
80
- time.hundredths || 0
81
- )
82
- .toString()
83
- .padStart(2, '0')}] ${item.text}`.trim();
84
- })
85
- .join('\n');
171
+ const syncedLyricLines = [];
172
+ let skippedSubtitleItems = 0;
173
+ for (const item of Array.isArray(syncedLines) ? syncedLines : []) {
174
+ const formattedLine = formatSubtitleTimestamp(item);
175
+ if (formattedLine) {
176
+ syncedLyricLines.push(formattedLine);
177
+ } else {
178
+ skippedSubtitleItems += 1;
179
+ }
180
+ }
181
+
182
+ if (skippedSubtitleItems > 0) {
183
+ logger.warn('Musixmatch subtitle items skipped due to unrecognized shape', {
184
+ skippedSubtitleItems,
185
+ sample: Array.isArray(syncedLines) ? syncedLines.slice(0, 2) : syncedLines,
186
+ trackId: meta.track_id,
187
+ trackName: meta.track_name,
188
+ artistName: meta.artist_name
189
+ });
190
+ }
191
+
192
+ const syncedLyrics = syncedLyricLines.join('\n');
86
193
 
87
194
  const plainLyrics = lyricsBody.split('\n').filter(Boolean).join('\n');
195
+ if (!plainLyrics && !syncedLyrics) {
196
+ logger.warn('Musixmatch matched track but returned no lyric content', {
197
+ trackId: meta.track_id,
198
+ trackName: meta.track_name,
199
+ artistName: meta.artist_name,
200
+ macroStatus: summarizeMacroStatus(body)
201
+ });
202
+ }
88
203
  const sourceUrl = meta.share_url || '';
89
204
 
90
205
  return normalizeLyricRecord({
@@ -107,25 +222,122 @@ function normalizeBody(body) {
107
222
  async function macroRequest(track) {
108
223
  await ensureMusixmatchToken();
109
224
  let token = await getMusixmatchToken();
110
- const attempt = async (tok) => {
225
+ let desktopCookie = await getMusixmatchDesktopCookie();
226
+ const topLevel401 = (payload) => payload?.message?.header?.status_code === 401;
227
+ const attempt = async (tok, cookie) => {
111
228
  const params = buildParams(track, tok);
229
+ const requestHeaders = {
230
+ ...DEFAULT_HEADERS,
231
+ Cookie: cookie || MXM_COOKIE_FALLBACK
232
+ };
112
233
  const response = await axios.get(`${BASE_URL}?${params.toString()}`, {
113
234
  timeout: HTTP_TIMEOUT_MS,
114
- headers: DEFAULT_HEADERS
235
+ headers: requestHeaders,
236
+ validateStatus: () => true,
237
+ responseType: 'text',
238
+ transformResponse: [(value) => value]
239
+ });
240
+ const contentType = response.headers?.['content-type'] || null;
241
+ const bodyPreview =
242
+ typeof response.data === 'string' ? response.data.slice(0, 240) : (response.data ?? null);
243
+
244
+ logger.debug('Musixmatch raw response received', {
245
+ httpStatus: response.status,
246
+ contentType,
247
+ dataType: typeof response.data,
248
+ bodyPreview,
249
+ trackTitle: track?.title || null,
250
+ trackArtist: track?.artist || null,
251
+ desktopCookiePresent: Boolean(cookie),
252
+ usingFallbackCookie: !cookie
115
253
  });
116
- return response.data?.message?.body?.macro_calls || response.data?.message?.body || {};
254
+
255
+ if (typeof response.data !== 'string') {
256
+ logger.warn('Musixmatch returned non-text raw payload', {
257
+ httpStatus: response.status,
258
+ contentType,
259
+ dataType: typeof response.data
260
+ });
261
+ }
262
+
263
+ if (typeof response.data === 'string') {
264
+ const trimmed = response.data.trim();
265
+ const looksLikeHtml =
266
+ contentType?.includes('text/html') ||
267
+ /^<!doctype html/i.test(trimmed) ||
268
+ /^<html[\s>]/i.test(trimmed);
269
+ if (looksLikeHtml) {
270
+ const error = new Error('Musixmatch returned HTML instead of JSON');
271
+ error.code = 'MUSIXMATCH_HTML_RESPONSE';
272
+ error.response = {
273
+ status: response.status,
274
+ data: response.data,
275
+ headers: response.headers
276
+ };
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ let payload;
282
+ try {
283
+ payload = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
284
+ } catch (parseError) {
285
+ logger.error('Failed to parse Musixmatch response as JSON', {
286
+ httpStatus: response.status,
287
+ contentType,
288
+ bodyPreview,
289
+ error: parseError
290
+ });
291
+ const error = new Error('Musixmatch returned invalid JSON');
292
+ error.code = 'MUSIXMATCH_INVALID_JSON';
293
+ error.response = {
294
+ status: response.status,
295
+ data: response.data,
296
+ headers: response.headers
297
+ };
298
+ throw error;
299
+ }
300
+
301
+ if (response.status === 401 || response.status === 403) {
302
+ const error = new Error(`Musixmatch HTTP ${response.status}`);
303
+ error.code =
304
+ response.status === 401 ? classifyUnauthorizedPayload(payload) : 'MUSIXMATCH_BLOCKED';
305
+ error.response = {
306
+ status: response.status,
307
+ data: payload,
308
+ headers: response.headers
309
+ };
310
+ throw error;
311
+ }
312
+
313
+ if (topLevel401(payload)) {
314
+ const error = new Error('Musixmatch unauthorized response');
315
+ error.code = classifyUnauthorizedPayload(payload);
316
+ error.response = {
317
+ status: 401,
318
+ data: payload,
319
+ headers: response.headers
320
+ };
321
+ throw error;
322
+ }
323
+ return payload?.message?.body?.macro_calls || payload?.message?.body || {};
117
324
  };
118
325
 
119
326
  try {
120
- return await attempt(token);
327
+ return await attempt(token, desktopCookie);
121
328
  } catch (error) {
122
329
  if (error.response?.status === 401 || error.response?.status === 403) {
123
- logger.warn('Musixmatch token rejected, invalidating', { status: error.response.status });
330
+ logger.warn('Musixmatch token rejected, invalidating', {
331
+ status: error.response.status,
332
+ desktopCookiePresent: Boolean(desktopCookie),
333
+ errorCode: error.code
334
+ });
124
335
  invalidateMusixmatchToken();
125
336
  token = await getMusixmatchToken();
337
+ desktopCookie = await getMusixmatchDesktopCookie();
126
338
  if (token) {
127
339
  try {
128
- return await attempt(token);
340
+ return await attempt(token, desktopCookie);
129
341
  } catch (retryError) {
130
342
  logger.error('Musixmatch retry failed', { error: retryError });
131
343
  }
@@ -154,6 +366,29 @@ export async function fetchFromMusixmatch(track) {
154
366
  const record = normalizeBody(body);
155
367
  return record;
156
368
  } catch (error) {
369
+ if (error.code === 'MUSIXMATCH_CAPTCHA' || error.code === 'MUSIXMATCH_HTML_RESPONSE') {
370
+ logger.warn('Musixmatch blocked by captcha challenge', {
371
+ trackTitle: track?.title || null,
372
+ trackArtist: track?.artist || null,
373
+ httpStatus: error.response?.status ?? null,
374
+ contentType: error.response?.headers?.['content-type'] ?? null
375
+ });
376
+ return buildBlockedRecord(track, error.response?.data, 'captcha');
377
+ }
378
+ if (
379
+ error.code === 'MUSIXMATCH_BLOCKED' ||
380
+ error.code === 'MUSIXMATCH_INVALID_JSON' ||
381
+ error.code === 'MUSIXMATCH_TOKEN_RENEW'
382
+ ) {
383
+ logger.warn('Musixmatch returned blocked or invalid response', {
384
+ trackTitle: track?.title || null,
385
+ trackArtist: track?.artist || null,
386
+ httpStatus: error.response?.status ?? null,
387
+ contentType: error.response?.headers?.['content-type'] ?? null,
388
+ errorCode: error.code
389
+ });
390
+ return buildBlockedRecord(track, error.response?.data, 'blocked');
391
+ }
157
392
  logger.error('Musixmatch request failed', { error });
158
393
  return null;
159
394
  }
@@ -166,5 +401,10 @@ export async function searchMusixmatch(track) {
166
401
 
167
402
  export async function checkMusixmatchTokenReady() {
168
403
  const token = await getMusixmatchToken();
404
+ const desktopCookie = await getMusixmatchDesktopCookie();
405
+ logger.debug('Musixmatch credential readiness checked', {
406
+ tokenPresent: Boolean(token),
407
+ desktopCookiePresent: Boolean(desktopCookie)
408
+ });
169
409
  return Boolean(token);
170
410
  }
@@ -7,13 +7,35 @@ import '../utils/config.js';
7
7
  import { describeKvBackend, isKvConfigured, kvSet } from '../utils/kv-store.js';
8
8
 
9
9
  const AUTH_URL = 'https://auth.musixmatch.com/';
10
+ const ACCOUNT_URL = 'https://account.musixmatch.com';
10
11
 
11
- async function saveToken(token, desktopCookie) {
12
+ async function waitForDesktopCookie(context, { attempts = 10, delayMs = 500 } = {}) {
13
+ let latestCookies = [];
14
+
15
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
16
+ latestCookies = await context.cookies(ACCOUNT_URL);
17
+ const desktopCookie = latestCookies.find((cookie) => cookie.name === 'web-desktop-app-v1.0');
18
+ if (desktopCookie) {
19
+ return { desktopCookie, cookies: latestCookies, attempts: attempt };
20
+ }
21
+
22
+ if (attempt < attempts) {
23
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
24
+ }
25
+ }
26
+
27
+ return { desktopCookie: null, cookies: latestCookies, attempts };
28
+ }
29
+
30
+ async function saveToken(token, desktopCookie, tokenPayload) {
12
31
  // Uses the same env var as the server runtime so both read/write the same path.
13
32
  const cachePath =
14
33
  process.env.MUSIXMATCH_TOKEN_CACHE || path.resolve('.cache', 'musixmatch-token.json');
15
34
  await mkdir(path.dirname(cachePath), { recursive: true });
16
35
  const payload = { token };
36
+ if (tokenPayload && typeof tokenPayload === 'object') {
37
+ payload.tokenPayload = tokenPayload;
38
+ }
17
39
  if (desktopCookie) {
18
40
  payload.desktopCookie = desktopCookie;
19
41
  }
@@ -22,11 +44,15 @@ async function saveToken(token, desktopCookie) {
22
44
  console.log('(Local and persistent servers read this file on startup.)');
23
45
  }
24
46
 
25
- async function saveToKv(token, desktopCookie) {
47
+ async function saveToKv(token, desktopCookie, tokenPayload) {
26
48
  if (!isKvConfigured()) return;
27
49
  const kvKey = process.env.MUSIXMATCH_TOKEN_KV_KEY || 'mr-magic:musixmatch-token';
28
50
  const kvTtl = parseInt(process.env.MUSIXMATCH_TOKEN_KV_TTL_SECONDS || '2592000', 10);
29
- const payload = JSON.stringify({ token, ...(desktopCookie ? { desktopCookie } : {}) });
51
+ const payload = JSON.stringify({
52
+ token,
53
+ ...(tokenPayload && typeof tokenPayload === 'object' ? { tokenPayload } : {}),
54
+ ...(desktopCookie ? { desktopCookie } : {})
55
+ });
30
56
  try {
31
57
  await kvSet(kvKey, payload, kvTtl);
32
58
  console.log(`Token written to KV store (${describeKvBackend()}) under key: ${kvKey}`);
@@ -35,11 +61,7 @@ async function saveToKv(token, desktopCookie) {
35
61
  }
36
62
  }
37
63
 
38
- function printDeploymentBlock(tokenValue) {
39
- const tokenString =
40
- typeof tokenValue === 'string'
41
- ? tokenValue
42
- : (tokenValue?.message?.body?.usertoken ?? JSON.stringify(tokenValue));
64
+ function printDeploymentBlock(tokenString) {
43
65
  const kvBackend = isKvConfigured() ? describeKvBackend() : null;
44
66
 
45
67
  console.log('\n' + '─'.repeat(68));
@@ -47,6 +69,7 @@ function printDeploymentBlock(tokenValue) {
47
69
 
48
70
  console.log('LOCAL & PERSISTENT SERVERS (cache token)');
49
71
  console.log(' Token written to .cache/musixmatch-token.json (or MUSIXMATCH_TOKEN_CACHE).');
72
+ console.log(' When available, the desktop cookie is written alongside the token.');
50
73
  console.log(' Any server with a writable, persistent filesystem (local dev, VPS,');
51
74
  console.log(' dedicated host) reads it automatically on startup.');
52
75
  console.log(' Re-run this script only when your token expires.\n');
@@ -78,6 +101,29 @@ function isHeadlessEnabled() {
78
101
  return value === '1' || value === 'true' || value === 'yes';
79
102
  }
80
103
 
104
+ function resolveTokenFromPayload(payload) {
105
+ if (!payload || typeof payload !== 'object') {
106
+ return null;
107
+ }
108
+
109
+ if (
110
+ typeof payload?.message?.body?.usertoken === 'string' &&
111
+ payload.message.body.usertoken.trim()
112
+ ) {
113
+ return payload.message.body.usertoken;
114
+ }
115
+
116
+ if (typeof payload?.tokens?.['web-desktop-app-v1.0'] === 'string') {
117
+ return payload.tokens['web-desktop-app-v1.0'].trim() || null;
118
+ }
119
+
120
+ if (typeof payload?.tokens?.['mxm-com-v1.0'] === 'string') {
121
+ return payload.tokens['mxm-com-v1.0'].trim() || null;
122
+ }
123
+
124
+ return null;
125
+ }
126
+
81
127
  async function main() {
82
128
  const headless = isHeadlessEnabled();
83
129
 
@@ -189,11 +235,17 @@ async function main() {
189
235
  // ERR_ABORTED on browsers like Comet that intercept or redirect during initial navigation.
190
236
  await page.goto(AUTH_URL, { waitUntil: 'commit' });
191
237
  console.log('Waiting to be redirected to https://account.musixmatch.com/ ...');
192
- await page.waitForURL('https://account.musixmatch.com/**', { timeout: 0 });
238
+ await page.waitForURL(`${ACCOUNT_URL}/**`, { timeout: 0 });
239
+ await page.waitForLoadState('domcontentloaded');
240
+ try {
241
+ await page.waitForLoadState('networkidle', { timeout: 5000 });
242
+ } catch {
243
+ // Best-effort stabilization: some pages keep background connections open.
244
+ }
193
245
 
194
- const cookies = await context.cookies('https://account.musixmatch.com');
246
+ const { desktopCookie, cookies, attempts } = await waitForDesktopCookie(context);
247
+ console.log(`Checked account.musixmatch.com cookies ${attempts} time(s) after login.`);
195
248
  const userCookie = cookies.find((cookie) => cookie.name === 'musixmatchUserToken');
196
- const desktopCookie = cookies.find((cookie) => cookie.name === 'web-desktop-app-v1.0');
197
249
  if (!userCookie) {
198
250
  console.error('musixmatchUserToken cookie not found; ensure you completed login.');
199
251
  process.exit(1);
@@ -210,18 +262,25 @@ async function main() {
210
262
  console.log('\nMusixmatch token payload:');
211
263
  console.log(JSON.stringify(parsed, null, 2));
212
264
 
265
+ const resolvedToken = resolveTokenFromPayload(parsed);
266
+ if (typeof resolvedToken !== 'string' || !resolvedToken.trim()) {
267
+ console.error('Unable to extract raw usertoken string from musixmatchUserToken payload.');
268
+ process.exit(1);
269
+ }
270
+
213
271
  const decodedDesktopCookie = desktopCookie ? decodeURIComponent(desktopCookie.value) : null;
272
+ console.log(`Desktop cookie captured: ${decodedDesktopCookie ? 'yes' : 'no'}`);
214
273
 
215
274
  // Write to all configured storage backends in parallel.
216
275
  await Promise.allSettled([
217
- saveToken(parsed, decodedDesktopCookie),
218
- saveToKv(parsed, decodedDesktopCookie)
276
+ saveToken(resolvedToken, decodedDesktopCookie, parsed),
277
+ saveToKv(resolvedToken, decodedDesktopCookie, parsed)
219
278
  ]);
220
279
 
221
280
  // Extract the raw token string for the deployment hint.
222
281
  // The parsed payload is the full musixmatchUserToken JSON object; the server
223
282
  // stores and reads the entire parsed object as the `token` field.
224
- printDeploymentBlock(parsed);
283
+ printDeploymentBlock(resolvedToken);
225
284
 
226
285
  await context.close();
227
286
  }
@@ -31,6 +31,36 @@ let cachedToken = null;
31
31
  let lastLoadedFrom = 'unknown';
32
32
  let cachedDesktopCookie = null;
33
33
 
34
+ function resolveStoredToken(parsed) {
35
+ if (!parsed) return null;
36
+
37
+ if (typeof parsed.token === 'string' && parsed.token.trim()) {
38
+ return parsed.token;
39
+ }
40
+
41
+ const nestedToken = parsed.token?.message?.body?.usertoken;
42
+ if (typeof nestedToken === 'string' && nestedToken.trim()) {
43
+ return nestedToken;
44
+ }
45
+
46
+ const payloadToken = parsed.tokenPayload?.message?.body?.usertoken;
47
+ if (typeof payloadToken === 'string' && payloadToken.trim()) {
48
+ return payloadToken;
49
+ }
50
+
51
+ const payloadDesktopToken = parsed.tokenPayload?.tokens?.['web-desktop-app-v1.0'];
52
+ if (typeof payloadDesktopToken === 'string' && payloadDesktopToken.trim()) {
53
+ return payloadDesktopToken;
54
+ }
55
+
56
+ const nestedDesktopToken = parsed.token?.tokens?.['web-desktop-app-v1.0'];
57
+ if (typeof nestedDesktopToken === 'string' && nestedDesktopToken.trim()) {
58
+ return nestedDesktopToken;
59
+ }
60
+
61
+ return null;
62
+ }
63
+
34
64
  function getCacheDir() {
35
65
  return path.dirname(TOKEN_CACHE_PATH);
36
66
  }
@@ -50,8 +80,9 @@ async function readCachedToken() {
50
80
  try {
51
81
  const raw = await fs.readFile(TOKEN_CACHE_PATH, 'utf8');
52
82
  const parsed = JSON.parse(raw);
53
- if (parsed?.token) {
54
- cachedToken = parsed.token;
83
+ const resolvedToken = resolveStoredToken(parsed);
84
+ if (resolvedToken) {
85
+ cachedToken = resolvedToken;
55
86
  cachedDesktopCookie = parsed.desktopCookie || null;
56
87
  lastLoadedFrom = 'cache';
57
88
  return cachedToken;
@@ -91,8 +122,9 @@ async function readKvToken() {
91
122
  const raw = await kvGet(KV_KEY);
92
123
  if (!raw) return null;
93
124
  const parsed = JSON.parse(raw);
94
- if (parsed?.token) {
95
- cachedToken = parsed.token;
125
+ const resolvedToken = resolveStoredToken(parsed);
126
+ if (resolvedToken) {
127
+ cachedToken = resolvedToken;
96
128
  cachedDesktopCookie = parsed.desktopCookie || null;
97
129
  lastLoadedFrom = `kv:${describeKvBackend()}`;
98
130
  return cachedToken;
@@ -145,6 +177,15 @@ export async function getMusixmatchToken() {
145
177
  return readCachedToken();
146
178
  }
147
179
 
180
+ export async function getMusixmatchDesktopCookie() {
181
+ if (cachedDesktopCookie) {
182
+ return cachedDesktopCookie;
183
+ }
184
+
185
+ await getMusixmatchToken();
186
+ return cachedDesktopCookie;
187
+ }
188
+
148
189
  export async function setMusixmatchToken(token, { desktopCookie } = {}) {
149
190
  if (!token) return;
150
191
  cachedToken = token;
@@ -196,7 +237,7 @@ export async function getMusixmatchTokenDiagnostics() {
196
237
  diagnostics.cacheFound = true;
197
238
  diagnostics.cacheBytes = raw.length;
198
239
  const parsed = JSON.parse(raw.toString('utf8'));
199
- diagnostics.cacheTokenPresent = Boolean(parsed?.token);
240
+ diagnostics.cacheTokenPresent = Boolean(resolveStoredToken(parsed));
200
241
  } catch (error) {
201
242
  diagnostics.cacheError = error?.code === 'ENOENT' ? null : (error?.message ?? null);
202
243
  }