mr-magic-mcp-server 0.3.11 → 0.4.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 +1 -1
- package/README.md +13 -0
- package/package.json +6 -6
- package/prompts/airtable-song-importer.md +4 -1
- package/src/bin/cli.js +8 -0
- package/src/index.js +13 -8
- package/src/providers/musixmatch.js +256 -16
- package/src/scripts/fetch_musixmatch_token.mjs +73 -14
- package/src/tests/run-tests.js +54 -0
- package/src/tools/cli.js +64 -1
- package/src/utils/tokens/musixmatch-token-manager.js +46 -5
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/
|
|
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/README.md
CHANGED
|
@@ -1161,12 +1161,19 @@ A single CLI entrypoint (`mrmagic-cli`) is published with the package. Inside th
|
|
|
1161
1161
|
local repo use `npm run cli -- <subcommand>` unless you have run `npm link` or
|
|
1162
1162
|
installed globally.
|
|
1163
1163
|
|
|
1164
|
+
Global CLI options:
|
|
1165
|
+
|
|
1166
|
+
- `--env-path <path>` / `--env-file <path>` — load credentials from a custom `.env` file
|
|
1167
|
+
before running the command. This is useful for global installs, `npm link`, and `npx`
|
|
1168
|
+
usage where the package install directory is not your project directory.
|
|
1169
|
+
|
|
1164
1170
|
### Commands
|
|
1165
1171
|
|
|
1166
1172
|
| Command | Purpose | Notable flags |
|
|
1167
1173
|
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
1168
1174
|
| `mrmagic-cli search` | List candidates across providers without downloading. | `--artist`, `--title`, `--provider`, `--duration`, `--show-all`, `--pick` |
|
|
1169
1175
|
| `mrmagic-cli find` | Resolve best lyric (prefers synced) and print / export. | `--providers`, `--synced-only`, `--export`, `--format`, `--output`, `--no-romanize`, `--choose`, `--index` |
|
|
1176
|
+
| `mrmagic-cli export` | Resolve best lyric and write export files directly. | `--providers`, `--synced-only`, `--format`, `--output`, `--no-romanize` |
|
|
1170
1177
|
| `mrmagic-cli select` | Pick first match from a prioritized provider list. | `--providers`, `--artist`, `--title`, `--require-synced` |
|
|
1171
1178
|
| `mrmagic-cli server` | Start the JSON automation API. | `--host`, `--port`, `--remote` |
|
|
1172
1179
|
| `mrmagic-cli server:mcp` | Start the MCP stdio server. | — |
|
|
@@ -1180,9 +1187,15 @@ installed globally.
|
|
|
1180
1187
|
# Search all providers
|
|
1181
1188
|
npm run cli -- search --artist "BLACKPINK" --title "Kill This Love"
|
|
1182
1189
|
|
|
1190
|
+
# Global/custom install with an explicit credential file
|
|
1191
|
+
mrmagic-cli --env-path /absolute/path/to/.env find --artist "Nayeon" --title "POP!"
|
|
1192
|
+
|
|
1183
1193
|
# Find best lyric (prefers synced LRC)
|
|
1184
1194
|
npm run cli -- find --artist "Nayeon" --title "POP!"
|
|
1185
1195
|
|
|
1196
|
+
# Export plain text and SRT files for the best match
|
|
1197
|
+
npm run cli -- export --artist "Nayeon" --title "POP!" --format plain --format srt --output ./exports
|
|
1198
|
+
|
|
1186
1199
|
# Pick first synced match from a prioritized provider list
|
|
1187
1200
|
npm run cli -- select --providers lrclib,genius --artist "Nayeon" --title "POP!" --require-synced
|
|
1188
1201
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-magic-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
64
|
+
"@dotenvx/dotenvx": "^1.61.1",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"axios": "^1.
|
|
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
|
|
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.
|
|
75
|
-
"prettier": "^3.8.
|
|
74
|
+
"playwright": "^1.59.1",
|
|
75
|
+
"prettier": "^3.8.3"
|
|
76
76
|
},
|
|
77
77
|
"overrides": {
|
|
78
78
|
"entities": "^7.0.1",
|
|
@@ -54,7 +54,7 @@ Examples:
|
|
|
54
54
|
- `BLACKPINK, Doja Cat, Absolutely - Crazy (Lyrics)`
|
|
55
55
|
- `Joji - Glimpse of Us (Lyrics)`
|
|
56
56
|
- `[GANG$] - Money (Remix) (Lyrics)`
|
|
57
|
-
- WRONG:`John Wick - This Song Is Lit (feat. BANKS) \\ RIGHT
|
|
57
|
+
- WRONG:`John Wick - This Song Is Lit (feat. BANKS) \\ RIGHT: `John Wick, BANKS - This Song Is Lit`
|
|
58
58
|
|
|
59
59
|
Artist names may contain brackets or special characters. Preserve them exactly.
|
|
60
60
|
|
|
@@ -85,6 +85,9 @@ Artist names may contain brackets or special characters. Preserve them exactly.
|
|
|
85
85
|
|
|
86
86
|
`build_catalog_payload` is the **required and exclusive lyric-resolution / lyric-preparation step for any Airtable entry**.
|
|
87
87
|
|
|
88
|
+
If you have an issue finding a lyric for a song with all the artists given, fallback to just using the first written artist, or the native language equivalent if found on Spotify.
|
|
89
|
+
|
|
90
|
+
If you have an issue finding a lyric for a song with the english title, use the native language version native language equivalent title of the song found from Spotify.
|
|
88
91
|
For every song that will be written to Airtable:
|
|
89
92
|
|
|
90
93
|
1. You **must** call `build_catalog_payload` before the Lyrics field can be written.
|
package/src/bin/cli.js
CHANGED
|
@@ -6,4 +6,12 @@
|
|
|
6
6
|
if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn';
|
|
7
7
|
if (!process.env.MR_MAGIC_QUIET_STDIO) process.env.MR_MAGIC_QUIET_STDIO = '1';
|
|
8
8
|
|
|
9
|
+
const envPathFlagIndex = process.argv.findIndex(
|
|
10
|
+
(arg) => arg === '--env-path' || arg === '--env-file'
|
|
11
|
+
);
|
|
12
|
+
if (envPathFlagIndex >= 0 && process.argv[envPathFlagIndex + 1]) {
|
|
13
|
+
process.env.MR_MAGIC_ENV_PATH = process.argv[envPathFlagIndex + 1];
|
|
14
|
+
process.argv.splice(envPathFlagIndex, 2);
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
await import('../tools/cli.js');
|
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
|
-
//
|
|
93
|
-
|
|
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:
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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', {
|
|
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
|
|
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({
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
218
|
-
saveToKv(
|
|
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(
|
|
283
|
+
printDeploymentBlock(resolvedToken);
|
|
225
284
|
|
|
226
285
|
await context.close();
|
|
227
286
|
}
|
package/src/tests/run-tests.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
3
7
|
|
|
4
8
|
import { selectMatch } from '../index.js';
|
|
5
9
|
import { buildChooserEntries, autoPick } from '../core/find-service.js';
|
|
@@ -431,6 +435,54 @@ async function testBuildPayloadFromResultNoCacheKeyWhenNoLyrics() {
|
|
|
431
435
|
console.log('buildPayloadFromResult omits lyricsCacheKey when best has no lyrics: ok');
|
|
432
436
|
}
|
|
433
437
|
|
|
438
|
+
function testCliExportCommandHelp() {
|
|
439
|
+
const output = execFileSync(process.execPath, ['src/bin/cli.js', 'export', '--help'], {
|
|
440
|
+
encoding: 'utf8',
|
|
441
|
+
env: {
|
|
442
|
+
...process.env,
|
|
443
|
+
LOG_LEVEL: 'error',
|
|
444
|
+
MR_MAGIC_QUIET_STDIO: '1'
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
assert.ok(output.includes('Find lyrics and write plain/LRC/SRT exports'));
|
|
449
|
+
assert.ok(output.includes('--format <format>'));
|
|
450
|
+
assert.ok(output.includes('--output <dir>'));
|
|
451
|
+
|
|
452
|
+
divider();
|
|
453
|
+
console.log('CLI export command help is available: ok');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function testCliEnvPathLoadsCustomEnvFile() {
|
|
457
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrmagic-cli-env-'));
|
|
458
|
+
const envPath = path.join(tempDir, '.env.custom');
|
|
459
|
+
fs.writeFileSync(envPath, 'GENIUS_DIRECT_TOKEN=cli-env-path-token\n', 'utf8');
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const output = execFileSync(
|
|
463
|
+
process.execPath,
|
|
464
|
+
['src/bin/cli.js', '--env-path', envPath, 'status'],
|
|
465
|
+
{
|
|
466
|
+
encoding: 'utf8',
|
|
467
|
+
env: {
|
|
468
|
+
PATH: process.env.PATH,
|
|
469
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
470
|
+
LOG_LEVEL: 'error',
|
|
471
|
+
MR_MAGIC_QUIET_STDIO: '1'
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
assert.ok(output.includes('genius'));
|
|
477
|
+
assert.ok(output.includes('Ready'), 'custom env file should make Genius status ready');
|
|
478
|
+
} finally {
|
|
479
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
divider();
|
|
483
|
+
console.log('CLI --env-path loads custom env files: ok');
|
|
484
|
+
}
|
|
485
|
+
|
|
434
486
|
async function run() {
|
|
435
487
|
testAutoPickPrefersSynced();
|
|
436
488
|
testAutoPickFallbackWhenNoSynced();
|
|
@@ -447,6 +499,8 @@ async function run() {
|
|
|
447
499
|
testRomanization();
|
|
448
500
|
await testBuildPayloadFromResultReturnsCacheKey();
|
|
449
501
|
await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
|
|
502
|
+
testCliExportCommandHelp();
|
|
503
|
+
testCliEnvPathLoadsCustomEnvFile();
|
|
450
504
|
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
451
505
|
console.log('MCP tooling available:', toolNames.join(', '));
|
|
452
506
|
console.log('All sanity checks passed');
|
package/src/tools/cli.js
CHANGED
|
@@ -107,7 +107,9 @@ const program = new Command();
|
|
|
107
107
|
program
|
|
108
108
|
.name('mrmagic-cli')
|
|
109
109
|
.description('Lyrics MCP server CLI powered by LRCLIB, Genius, Musixmatch, and Melon')
|
|
110
|
-
.version('0.1.3')
|
|
110
|
+
.version('0.1.3')
|
|
111
|
+
.option('--env-path <path>', 'Path to a .env file to load before running a CLI command')
|
|
112
|
+
.option('--env-file <path>', 'Alias for --env-path');
|
|
111
113
|
|
|
112
114
|
function normalizeFormatOptions(value) {
|
|
113
115
|
if (!value) return [];
|
|
@@ -699,6 +701,67 @@ program
|
|
|
699
701
|
}
|
|
700
702
|
});
|
|
701
703
|
|
|
704
|
+
program
|
|
705
|
+
.command('export')
|
|
706
|
+
.description('Find lyrics and write plain/LRC/SRT exports')
|
|
707
|
+
.requiredOption('--artist <name>', 'Artist name')
|
|
708
|
+
.requiredOption('--title <name>', 'Song title')
|
|
709
|
+
.option('--album <name>', 'Album name')
|
|
710
|
+
.option('--duration <ms>', 'Track duration in milliseconds')
|
|
711
|
+
.option('--providers <list>', 'Comma-separated provider list')
|
|
712
|
+
.option('--synced-only', 'Require synced lyrics', false)
|
|
713
|
+
.option(
|
|
714
|
+
'--format <format>',
|
|
715
|
+
'Export format (plain|lrc|srt). repeatable',
|
|
716
|
+
(value, acc) => {
|
|
717
|
+
acc.push(value);
|
|
718
|
+
return acc;
|
|
719
|
+
},
|
|
720
|
+
[]
|
|
721
|
+
)
|
|
722
|
+
.option('--output <dir>', 'Directory for exports (defaults to MR_MAGIC_EXPORT_DIR or ./exports)')
|
|
723
|
+
.option('--no-romanize', 'Disable romanized lyrics', false)
|
|
724
|
+
.action(async (options) => {
|
|
725
|
+
const track = buildTrackFromOptions(options);
|
|
726
|
+
const searchLabel = [track.artist, track.title].filter(Boolean).join(' - ');
|
|
727
|
+
process.stderr.write(`Searching: ${searchLabel}...\n`);
|
|
728
|
+
const providerNames = options.providers
|
|
729
|
+
? options.providers.split(',').map((value) => value.trim())
|
|
730
|
+
: [];
|
|
731
|
+
const result = await runFind(track, {
|
|
732
|
+
providerNames,
|
|
733
|
+
syncedOnly: options.syncedOnly
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
if (!result.best) {
|
|
737
|
+
console.error('No best match available');
|
|
738
|
+
process.exitCode = 1;
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const includeRomanization = options.noRomanize ? false : true;
|
|
743
|
+
const exports = await exportLyrics(result.best, {
|
|
744
|
+
formats: deriveFormatSet(options.format),
|
|
745
|
+
output: options.output,
|
|
746
|
+
includeRomanization
|
|
747
|
+
});
|
|
748
|
+
console.log(
|
|
749
|
+
JSON.stringify(
|
|
750
|
+
{
|
|
751
|
+
picked: {
|
|
752
|
+
provider: result.best.provider || 'unknown',
|
|
753
|
+
synced: Boolean(result.best.synced),
|
|
754
|
+
artist: result.best.artist || track.artist || 'unknown',
|
|
755
|
+
title: result.best.title || track.title || 'song'
|
|
756
|
+
},
|
|
757
|
+
exports
|
|
758
|
+
},
|
|
759
|
+
null,
|
|
760
|
+
2
|
|
761
|
+
)
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
|
|
702
765
|
program
|
|
703
766
|
.command('search-provider')
|
|
704
767
|
.description('Search a specific provider only')
|
|
@@ -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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
240
|
+
diagnostics.cacheTokenPresent = Boolean(resolveStoredToken(parsed));
|
|
200
241
|
} catch (error) {
|
|
201
242
|
diagnostics.cacheError = error?.code === 'ENOENT' ? null : (error?.message ?? null);
|
|
202
243
|
}
|