mr-magic-mcp-server 0.1.5
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 +26 -0
- package/LICENSE +21 -0
- package/README.md +1118 -0
- package/package.json +75 -0
- package/prompts/airtable-song-importer.md +239 -0
- package/src/bin/cli.js +9 -0
- package/src/bin/http-server.js +7 -0
- package/src/bin/mcp-http-server.js +7 -0
- package/src/bin/mcp-server.js +6 -0
- package/src/core/export.js +83 -0
- package/src/core/find-service.js +66 -0
- package/src/core/formatting.js +39 -0
- package/src/core/preview.js +54 -0
- package/src/index.js +138 -0
- package/src/provider-result-schema.js +63 -0
- package/src/providers/genius.js +250 -0
- package/src/providers/lrclib.js +73 -0
- package/src/providers/melon.js +201 -0
- package/src/providers/musixmatch.js +165 -0
- package/src/services/airtable-writer.js +246 -0
- package/src/services/lyrics-service.js +372 -0
- package/src/tools/cli.js +695 -0
- package/src/transport/http-server.js +151 -0
- package/src/transport/mcp-http-server.js +202 -0
- package/src/transport/mcp-response.js +44 -0
- package/src/transport/mcp-server.js +77 -0
- package/src/transport/mcp-tools.js +513 -0
- package/src/transport/token-startup-log.js +133 -0
- package/src/transport/tool-args.js +113 -0
- package/src/utils/config.js +57 -0
- package/src/utils/export-storage/inline-storage.js +7 -0
- package/src/utils/export-storage/local-storage.js +39 -0
- package/src/utils/export-storage/redis-storage.js +36 -0
- package/src/utils/export-storage/shared-redis-client.js +69 -0
- package/src/utils/export-storage.js +29 -0
- package/src/utils/logger.js +74 -0
- package/src/utils/lyrics-format.js +170 -0
- package/src/utils/slugify.js +18 -0
- package/src/utils/storage-cache.js +29 -0
- package/src/utils/tokens/genius-token-manager.js +119 -0
- package/src/utils/tokens/musixmatch-token-manager.js +130 -0
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mr-magic-mcp-server",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Lyrics MCP server connecting LRCLIB, Genius, Musixmatch, and Melon",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"mrmagic-cli": "src/bin/cli.js",
|
|
12
|
+
"http-server": "src/bin/http-server.js",
|
|
13
|
+
"mcp-server": "src/bin/mcp-server.js",
|
|
14
|
+
"mcp-http-server": "src/bin/mcp-http-server.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"prompts",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
".env.example"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"cli": "node src/bin/cli.js",
|
|
25
|
+
"server:http": "node src/bin/http-server.js",
|
|
26
|
+
"server:mcp": "node src/bin/mcp-server.js",
|
|
27
|
+
"server:mcp:http": "node src/bin/mcp-http-server.js",
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"lint:fix": "eslint . --fix",
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check .",
|
|
32
|
+
"test": "node tests/run-tests.js",
|
|
33
|
+
"fetch:musixmatch-token": "node scripts/fetch_musixmatch_token.mjs",
|
|
34
|
+
"fetch:genius-token": "node scripts/fetch_genius_token.mjs",
|
|
35
|
+
"repro:mcp:arg-boundary": "node scripts/mcp-arg-boundary-repro.mjs",
|
|
36
|
+
"repro:mcp:arg-boundary:sdk": "node scripts/mcp-arg-boundary-sdk-repro.mjs",
|
|
37
|
+
"prepack": "npm run test"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"lyrics",
|
|
41
|
+
"mcp",
|
|
42
|
+
"lrclib",
|
|
43
|
+
"genius",
|
|
44
|
+
"musixmatch",
|
|
45
|
+
"melon"
|
|
46
|
+
],
|
|
47
|
+
"author": "Kenyatta Naji Johnson-Adams",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/mrnajiboy/mr-magic-mcp-server.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/mrnajiboy/mr-magic-mcp-server/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/mrnajiboy/mr-magic-mcp-server#readme",
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18.17"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
62
|
+
"axios": "^1.7.2",
|
|
63
|
+
"cheerio": "^1.0.0-rc.12",
|
|
64
|
+
"commander": "^10.0.0",
|
|
65
|
+
"dotenv": "^16.4.0",
|
|
66
|
+
"hangul-js": "^0.2.6"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"eslint": "^9.39.4",
|
|
70
|
+
"eslint-config-prettier": "^10.1.8",
|
|
71
|
+
"eslint-plugin-import": "^2.32.0",
|
|
72
|
+
"playwright-chromium": "^1.58.2",
|
|
73
|
+
"prettier": "^3.8.1"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
You are an Airtable Song Importing Assistant. You are helping the user add songs to an Airtable catalog.
|
|
2
|
+
|
|
3
|
+
The user will provide:
|
|
4
|
+
|
|
5
|
+
- a list of songs
|
|
6
|
+
- and either a base name or base ID, if available
|
|
7
|
+
|
|
8
|
+
If you are not sure you have all required information, ask the user before proceeding.
|
|
9
|
+
|
|
10
|
+
For each batch of songs in the user's list, follow this workflow.
|
|
11
|
+
|
|
12
|
+
## 1) Resolve Airtable destination
|
|
13
|
+
|
|
14
|
+
Use Airtable tools to determine the correct:
|
|
15
|
+
|
|
16
|
+
- base ID
|
|
17
|
+
- table ID
|
|
18
|
+
- target field IDs or field names
|
|
19
|
+
|
|
20
|
+
Always verify field IDs against field names before inserting or updating records.
|
|
21
|
+
|
|
22
|
+
## 2) Required Airtable fields
|
|
23
|
+
|
|
24
|
+
For every song, populate only these Airtable fields:
|
|
25
|
+
|
|
26
|
+
- Song (Video)
|
|
27
|
+
- Listen Link
|
|
28
|
+
- Lyrics
|
|
29
|
+
|
|
30
|
+
Never send extra metadata or unused fields to Airtable.
|
|
31
|
+
|
|
32
|
+
## 3) Song (Video) formatting rules
|
|
33
|
+
|
|
34
|
+
`Song (Video)` must always be formatted exactly as:
|
|
35
|
+
`{Artist 1}, {Artist 2} - {Title} (Lyrics)`
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
|
|
39
|
+
- `BLACKPINK, Doja Cat, Absolutely - Crazy (Lyrics)`
|
|
40
|
+
- `Joji - Glimpse of Us (Lyrics)`
|
|
41
|
+
- `[GANG$] - Money (Remix) (Lyrics)`
|
|
42
|
+
|
|
43
|
+
Artist names may contain brackets or special characters. Preserve them exactly.
|
|
44
|
+
|
|
45
|
+
## 4) Listen Link rules
|
|
46
|
+
|
|
47
|
+
Use the Spotify song lookup tool.
|
|
48
|
+
Use the `URL` value for the Airtable `Listen Link` field.
|
|
49
|
+
Spotify link resolution must always be handled separately from lyric resolution.
|
|
50
|
+
|
|
51
|
+
## 5) Lyrics resolution rules
|
|
52
|
+
|
|
53
|
+
Use the `build_catalog_payload` tool to resolve lyrics for each song.
|
|
54
|
+
|
|
55
|
+
Call it with:
|
|
56
|
+
|
|
57
|
+
- `preferRomanized: true`
|
|
58
|
+
|
|
59
|
+
The response will include:
|
|
60
|
+
|
|
61
|
+
- `lyricsCacheKey` — a short slug identifying the cached lyrics server-side
|
|
62
|
+
- `songVideoTitle` — use this to cross-check the `Song (Video)` field formatting
|
|
63
|
+
|
|
64
|
+
Do **not** copy any lyric text out of `build_catalog_payload`. The full lyrics are handled server-side by `push_catalog_to_airtable`. Never relay lyrics text through tool-call arguments.
|
|
65
|
+
|
|
66
|
+
### Lyric priority (handled automatically by push_catalog_to_airtable)
|
|
67
|
+
|
|
68
|
+
1. Romanized plain lyrics, if available (default when `preferRomanized: true`)
|
|
69
|
+
2. Otherwise plain lyrics
|
|
70
|
+
|
|
71
|
+
## 6) Airtable record write rules
|
|
72
|
+
|
|
73
|
+
Use Airtable MCP tools (`create_records_for_table` / `update_records_for_table`) for **Song (Video)** and **Listen Link** fields. These tools support bulk writes of up to 10 records per call — use that fully.
|
|
74
|
+
|
|
75
|
+
**STRICTLY FORBIDDEN:** Never use `create_records_for_table` or `update_records_for_table` to write or update the `Lyrics` field. Those tools cannot handle long multiline lyric text without JSON truncation errors.
|
|
76
|
+
|
|
77
|
+
**Always use `push_catalog_to_airtable` to write the Lyrics field.** This tool makes the Airtable API call server-side — lyrics are fetched from the internal cache and the lyric text never passes through your tool-call arguments.
|
|
78
|
+
|
|
79
|
+
### How to call push_catalog_to_airtable
|
|
80
|
+
|
|
81
|
+
Pass:
|
|
82
|
+
|
|
83
|
+
- `baseId` — from Airtable MCP `search_bases` result
|
|
84
|
+
- `tableId` — from Airtable MCP `list_tables_for_base` result
|
|
85
|
+
- `recordId` — the record ID returned from the create step (required to update the Lyrics field)
|
|
86
|
+
- `fields` — pass an **empty object `{}`** (no non-lyrics fields; those were already written in the create step)
|
|
87
|
+
- `lyricsFieldId` — the field ID for the Lyrics field
|
|
88
|
+
- `lyricsCacheKey` — the value returned by `build_catalog_payload`
|
|
89
|
+
- `preferRomanized: true`
|
|
90
|
+
|
|
91
|
+
Do NOT include the lyrics text itself in `fields`. Do NOT include `lyricsFieldId` in `fields`.
|
|
92
|
+
|
|
93
|
+
### Example push_catalog_to_airtable call shape (lyrics-only update)
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"baseId": "appeBUkVEp3N4RT0C",
|
|
98
|
+
"tableId": "tbl0y5XHFXpjUJXHu",
|
|
99
|
+
"recordId": "rec1234567890abcd",
|
|
100
|
+
"fields": {},
|
|
101
|
+
"lyricsFieldId": "fldHV1qmPYmsvglff",
|
|
102
|
+
"lyricsCacheKey": "kda-feat-twice-bekuh-boom-annika-wells-league-of-legends-ill-show-you",
|
|
103
|
+
"preferRomanized": true
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### If push_catalog_to_airtable fails
|
|
108
|
+
|
|
109
|
+
If the lyrics write fails, retry with `splitLyricsUpdate: true`. This updates the Lyrics field in a separate second call — entirely server-side.
|
|
110
|
+
|
|
111
|
+
## 7) Bulk execution order
|
|
112
|
+
|
|
113
|
+
Process all songs in the user's list together as a batch, not one at a time.
|
|
114
|
+
|
|
115
|
+
### Phase 1 — Resolve all data in parallel
|
|
116
|
+
|
|
117
|
+
For every song in the batch (up to all at once):
|
|
118
|
+
|
|
119
|
+
1. Resolve Airtable destination info (`search_bases`, `list_tables_for_base`) — do this once per base/table, not per song.
|
|
120
|
+
2. Resolve Spotify link (`search-spotify`) for each song.
|
|
121
|
+
3. Resolve lyrics (`build_catalog_payload` with `preferRomanized: true`) for each song. Save each song's `lyricsCacheKey`.
|
|
122
|
+
|
|
123
|
+
### Phase 2 — Bulk create/update records (Song (Video) + Listen Link only)
|
|
124
|
+
|
|
125
|
+
Use `create_records_for_table` (or `update_records_for_table` if updating existing records) to write **Song (Video)** and **Listen Link** for all songs in the batch.
|
|
126
|
+
|
|
127
|
+
- Send up to **10 records per call**.
|
|
128
|
+
- If the batch has more than 10 songs, split into multiple calls of up to 10 each.
|
|
129
|
+
- Capture the `recordId` returned for each newly created record — you will need these in Phase 3.
|
|
130
|
+
|
|
131
|
+
Example batch create body (Song (Video) + Listen Link only — no Lyrics):
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"records": [
|
|
136
|
+
{
|
|
137
|
+
"fields": {
|
|
138
|
+
"fldM1p1Ou01SQlDrN": "K/DA - POP/STARS (Lyrics)",
|
|
139
|
+
"fld0NIKYPaokLjj1G": "https://open.spotify.com/track/497qmwcUsCv5hmMU0K8Hik"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"fields": {
|
|
144
|
+
"fldM1p1Ou01SQlDrN": "Joji - Glimpse of Us (Lyrics)",
|
|
145
|
+
"fld0NIKYPaokLjj1G": "https://open.spotify.com/track/1BxfuPKGuaTgP7aM0Bbdwr"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Phase 3 — Write lyrics for each record via push_catalog_to_airtable
|
|
153
|
+
|
|
154
|
+
For each record created in Phase 2, call `push_catalog_to_airtable` with:
|
|
155
|
+
|
|
156
|
+
- The `recordId` from Phase 2
|
|
157
|
+
- `fields: {}` (non-lyrics fields already written)
|
|
158
|
+
- `lyricsFieldId` for the Lyrics field
|
|
159
|
+
- `lyricsCacheKey` from Phase 1 for that song
|
|
160
|
+
- `preferRomanized: true`
|
|
161
|
+
|
|
162
|
+
**One `push_catalog_to_airtable` call per song** (lyrics are per-track, not batchable).
|
|
163
|
+
|
|
164
|
+
### Phase 4 — SRT export
|
|
165
|
+
|
|
166
|
+
After all Airtable inserts and lyrics writes succeed:
|
|
167
|
+
|
|
168
|
+
- Export `.SRT` lyrics using the `export_lyrics` tool for each song.
|
|
169
|
+
- Confirm the user has received at least one of the following:
|
|
170
|
+
- SRT download link
|
|
171
|
+
- SRT file path
|
|
172
|
+
- export folder location
|
|
173
|
+
- inline SRT content as fallback
|
|
174
|
+
|
|
175
|
+
## 8) Required export step after Airtable succeeds
|
|
176
|
+
|
|
177
|
+
After all Airtable inserts succeed, the agent must export synced lyrics as `.SRT`.
|
|
178
|
+
This export step is required and must be handled separately from Airtable insertion.
|
|
179
|
+
|
|
180
|
+
### Export priority
|
|
181
|
+
|
|
182
|
+
For SRT export, use this priority order:
|
|
183
|
+
|
|
184
|
+
1. romanized synced lyrics, if available
|
|
185
|
+
2. otherwise synced lyrics
|
|
186
|
+
|
|
187
|
+
### Export delivery requirement
|
|
188
|
+
|
|
189
|
+
The agent must make sure the user receives at least one of the following:
|
|
190
|
+
|
|
191
|
+
- an SRT download link
|
|
192
|
+
- an exported SRT file path
|
|
193
|
+
- an exported folder location containing the SRT file
|
|
194
|
+
|
|
195
|
+
If file export succeeds, report exactly where the SRT can be retrieved.
|
|
196
|
+
At minimum, provide one of:
|
|
197
|
+
|
|
198
|
+
- `exports.srt.url`
|
|
199
|
+
- `exports.srt.filePath`
|
|
200
|
+
- the output folder path used for export
|
|
201
|
+
|
|
202
|
+
If the export backend does not provide a downloadable file or writable folder, the agent must still complete the export step and then provide the inline SRT content returned by the tool.
|
|
203
|
+
|
|
204
|
+
### Export tool rules
|
|
205
|
+
|
|
206
|
+
Use the `export_lyrics` tool to export `.SRT` output after Airtable insertion is complete.
|
|
207
|
+
Prefer synced romanized output when available; otherwise use synced lyrics.
|
|
208
|
+
Do not confuse Airtable lyric insertion with export output:
|
|
209
|
+
|
|
210
|
+
- Airtable `Lyrics` must contain only plain-text lyrics (handled server-side)
|
|
211
|
+
- export output may be synced `.SRT`
|
|
212
|
+
|
|
213
|
+
If the tool returns a URL, present that URL clearly.
|
|
214
|
+
If the tool returns a file path, present that file path clearly.
|
|
215
|
+
If the tool returns inline content because persistence was skipped or failed, provide the SRT content in the conversation and clearly explain that no downloadable file/link was available.
|
|
216
|
+
|
|
217
|
+
## 9) Progress reporting rules
|
|
218
|
+
|
|
219
|
+
When reporting progress:
|
|
220
|
+
|
|
221
|
+
- show how many songs are being processed in the current batch
|
|
222
|
+
- confirm each Airtable bulk create/update result (report record IDs created)
|
|
223
|
+
- confirm each lyrics write separately (report `success`, `recordId`, `lyricsWritten` from `push_catalog_to_airtable`)
|
|
224
|
+
- clearly separate Airtable insertion phases from SRT export
|
|
225
|
+
- if a fallback or retry was required, state exactly which step failed and what workaround was used
|
|
226
|
+
|
|
227
|
+
## 10) Tool responsibility summary
|
|
228
|
+
|
|
229
|
+
| Step | Tool (MCP Server) | Bulk? |
|
|
230
|
+
| ------------------------------------ | -------------------------------------------------------------------------- | --------------------- |
|
|
231
|
+
| Find base/table | `search_bases`, `list_tables_for_base` (Airtable MCP) | Once per base |
|
|
232
|
+
| Spotify link | `search-spotify` (Spotify MCP) | Per song |
|
|
233
|
+
| Lyrics resolution | `build_catalog_payload` (mr-magic) | Per song |
|
|
234
|
+
| **Song (Video) + Listen Link write** | **`create_records_for_table` / `update_records_for_table` (Airtable MCP)** | **Up to 10 per call** |
|
|
235
|
+
| **Lyrics write** | **`push_catalog_to_airtable` (mr-magic) — always** | Per song |
|
|
236
|
+
| SRT export | `export_lyrics` (mr-magic) | Per song |
|
|
237
|
+
|
|
238
|
+
**Never use `create_records_for_table` or `update_records_for_table` for the Lyrics field.**
|
|
239
|
+
Always use `push_catalog_to_airtable` for Lyrics — no exceptions.
|
package/src/bin/cli.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Reduce structured-log noise and env-missing warnings for interactive CLI usage.
|
|
4
|
+
// These must be set before any module that reads them is evaluated, so we use
|
|
5
|
+
// a dynamic import below instead of a static one.
|
|
6
|
+
if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn';
|
|
7
|
+
if (!process.env.MR_MAGIC_QUIET_STDIO) process.env.MR_MAGIC_QUIET_STDIO = '1';
|
|
8
|
+
|
|
9
|
+
await import('../tools/cli.js');
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildLrc,
|
|
3
|
+
buildSrt,
|
|
4
|
+
formatPlainStanzas,
|
|
5
|
+
romanizePlainLyrics,
|
|
6
|
+
romanizeSyncedLyrics,
|
|
7
|
+
romanizeSrtLyrics,
|
|
8
|
+
containsHangul
|
|
9
|
+
} from '../utils/lyrics-format.js';
|
|
10
|
+
import { slugify } from '../utils/slugify.js';
|
|
11
|
+
import { createStorageCache } from '../utils/storage-cache.js';
|
|
12
|
+
|
|
13
|
+
const getStorage = createStorageCache(`${process.env.MR_MAGIC_EXPORT_BACKEND || 'local'}:`);
|
|
14
|
+
|
|
15
|
+
async function storeExport(outputDir, baseName, extension, contents) {
|
|
16
|
+
if (!contents) return null;
|
|
17
|
+
const safe = slugify(baseName || 'lyrics', 'lyrics');
|
|
18
|
+
const storage = await getStorage(outputDir);
|
|
19
|
+
return storage.store({ content: contents, extension, baseName: safe });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function exportLyrics(record, options) {
|
|
23
|
+
const exports = {};
|
|
24
|
+
if (!record.plainLyrics) {
|
|
25
|
+
return exports;
|
|
26
|
+
}
|
|
27
|
+
const baseName = `${record.artist || 'unknown'}-${record.title || 'song'}`;
|
|
28
|
+
if (options.formats.includes('plain')) {
|
|
29
|
+
exports.plain = await storeExport(
|
|
30
|
+
options.output,
|
|
31
|
+
baseName,
|
|
32
|
+
'txt',
|
|
33
|
+
formatPlainStanzas(record.plainLyrics)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (options.formats.includes('lrc')) {
|
|
37
|
+
exports.lrc = await storeExport(options.output, baseName, 'lrc', buildLrc(record.syncedLyrics));
|
|
38
|
+
}
|
|
39
|
+
if (options.formats.includes('srt')) {
|
|
40
|
+
exports.srt = await storeExport(options.output, baseName, 'srt', buildSrt(record.syncedLyrics));
|
|
41
|
+
}
|
|
42
|
+
if (options.includeRomanization !== false) {
|
|
43
|
+
const hasHangulPlain = containsHangul(record.plainLyrics);
|
|
44
|
+
const hasHangulSynced = record.syncedLyrics && containsHangul(record.syncedLyrics);
|
|
45
|
+
|
|
46
|
+
if (hasHangulPlain && options.formats.includes('plain')) {
|
|
47
|
+
exports.romanizedPlain = await storeExport(
|
|
48
|
+
options.output,
|
|
49
|
+
baseName,
|
|
50
|
+
'romanized.txt',
|
|
51
|
+
romanizePlainLyrics(record.plainLyrics, { formatted: true })
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (hasHangulSynced && options.formats.includes('lrc')) {
|
|
56
|
+
exports.romanizedLrc = await storeExport(
|
|
57
|
+
options.output,
|
|
58
|
+
baseName,
|
|
59
|
+
'romanized.lrc',
|
|
60
|
+
romanizeSyncedLyrics(record.syncedLyrics)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (hasHangulSynced && options.formats.includes('srt')) {
|
|
65
|
+
exports.romanizedSrt = await storeExport(
|
|
66
|
+
options.output,
|
|
67
|
+
baseName,
|
|
68
|
+
'romanized.srt',
|
|
69
|
+
romanizeSrtLyrics(record.syncedLyrics)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return exports;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function deriveFormatSet(requestedFormats, defaultFormats = ['plain', 'srt']) {
|
|
77
|
+
const base = ['plain', 'lrc', 'srt'];
|
|
78
|
+
if (requestedFormats && requestedFormats.length > 0) {
|
|
79
|
+
const normalized = requestedFormats.map((format) => format?.toLowerCase()).filter(Boolean);
|
|
80
|
+
return Array.from(new Set(normalized.filter((format) => base.includes(format))));
|
|
81
|
+
}
|
|
82
|
+
return [...defaultFormats];
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { findLyrics, findSyncedLyrics, searchProvider, searchSources } from '../index.js';
|
|
2
|
+
|
|
3
|
+
export function normalizeTrack(track = {}) {
|
|
4
|
+
if (!track || typeof track !== 'object') {
|
|
5
|
+
return { title: '', artist: '', album: null, duration: null };
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
title: track.title?.trim() || '',
|
|
9
|
+
artist: track.artist?.trim() || '',
|
|
10
|
+
album: track.album?.trim() || null,
|
|
11
|
+
duration:
|
|
12
|
+
typeof track.duration === 'number'
|
|
13
|
+
? track.duration
|
|
14
|
+
: Number.isFinite(Number(track.duration))
|
|
15
|
+
? Number(track.duration)
|
|
16
|
+
: null
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runFind(track, options = {}) {
|
|
21
|
+
const providerNames = options.providerNames || [];
|
|
22
|
+
const finder = options.syncedOnly ? findSyncedLyrics : findLyrics;
|
|
23
|
+
const normalizedTrack = normalizeTrack(track);
|
|
24
|
+
const result = await finder(normalizedTrack, {
|
|
25
|
+
providerNames,
|
|
26
|
+
syncedOnly: options.syncedOnly
|
|
27
|
+
});
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runSearch(track) {
|
|
32
|
+
return searchSources(normalizeTrack(track));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runProviderSearch(providerName, track) {
|
|
36
|
+
return searchProvider(providerName, normalizeTrack(track));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildChooserEntries(matches) {
|
|
40
|
+
return matches.map((entry, idx) => ({
|
|
41
|
+
index: idx + 1,
|
|
42
|
+
provider: entry.provider,
|
|
43
|
+
result: entry.result
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function pickIndex(entries, index) {
|
|
48
|
+
if (!entries.length) return null;
|
|
49
|
+
if (!index) return entries[0].result;
|
|
50
|
+
const parsedIndex = Number(index);
|
|
51
|
+
if (!Number.isInteger(parsedIndex) || parsedIndex < 1 || parsedIndex > entries.length) {
|
|
52
|
+
throw new Error(`Invalid index. Provide an integer between 1 and ${entries.length}.`);
|
|
53
|
+
}
|
|
54
|
+
return entries[parsedIndex - 1].result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function autoPick(entries, preferSynced = true) {
|
|
58
|
+
if (!entries.length) return null;
|
|
59
|
+
if (preferSynced) {
|
|
60
|
+
const synced = entries.find((entry) => entry.result?.synced);
|
|
61
|
+
if (synced) {
|
|
62
|
+
return synced.result;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return entries[0].result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatPlainStanzas,
|
|
3
|
+
romanizePlainLyrics,
|
|
4
|
+
romanizeSyncedLyrics,
|
|
5
|
+
romanizeSrtLyrics,
|
|
6
|
+
containsHangul
|
|
7
|
+
} from '../utils/lyrics-format.js';
|
|
8
|
+
|
|
9
|
+
export function formatRecord(record, options = {}) {
|
|
10
|
+
const includeRomanization = options.includeRomanization !== false;
|
|
11
|
+
const includeSynced = options.includeSynced !== false;
|
|
12
|
+
const plain = formatPlainStanzas(record.plainLyrics);
|
|
13
|
+
|
|
14
|
+
const response = {
|
|
15
|
+
provider: record.provider,
|
|
16
|
+
title: record.title,
|
|
17
|
+
artist: record.artist,
|
|
18
|
+
album: record.album,
|
|
19
|
+
sourceUrl: record.sourceUrl,
|
|
20
|
+
synced: record.synced,
|
|
21
|
+
confidence: record.confidence,
|
|
22
|
+
plainLyrics: plain
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (includeRomanization && containsHangul(record.plainLyrics)) {
|
|
26
|
+
response.romanizedPlain = romanizePlainLyrics(record.plainLyrics, { formatted: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (includeRomanization && record.syncedLyrics && containsHangul(record.syncedLyrics)) {
|
|
30
|
+
response.romanizedLrc = romanizeSyncedLyrics(record.syncedLyrics);
|
|
31
|
+
response.romanizedSrt = romanizeSrtLyrics(record.syncedLyrics);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (includeSynced && record.syncedLyrics) {
|
|
35
|
+
response.syncedLyrics = record.syncedLyrics;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return response;
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const PREVIEW_MAX_LENGTH = 140;
|
|
2
|
+
|
|
3
|
+
function truncatePreview(text) {
|
|
4
|
+
if (!text) return '';
|
|
5
|
+
if (text.length <= PREVIEW_MAX_LENGTH) {
|
|
6
|
+
return text;
|
|
7
|
+
}
|
|
8
|
+
return `${text.slice(0, PREVIEW_MAX_LENGTH - 1)}…`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function extractPlainPreview(record) {
|
|
12
|
+
if (!record?.plainLyrics) return '';
|
|
13
|
+
const lines = record.plainLyrics.split('\n').map((entry) => entry && entry.trim());
|
|
14
|
+
const primary = lines.find(
|
|
15
|
+
(entry) =>
|
|
16
|
+
entry &&
|
|
17
|
+
!entry.toLowerCase().includes('lyrics”') &&
|
|
18
|
+
!entry.toLowerCase().startsWith('read more') &&
|
|
19
|
+
!entry.toLowerCase().startsWith('[verse') &&
|
|
20
|
+
!entry.toLowerCase().startsWith('[hook') &&
|
|
21
|
+
!entry.toLowerCase().startsWith('[chorus')
|
|
22
|
+
);
|
|
23
|
+
const fallback = lines.find((entry) => entry);
|
|
24
|
+
return truncatePreview(primary || fallback || '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function extractSyncedPreview(record) {
|
|
28
|
+
if (!record?.syncedLyrics || !record?.synced) return '';
|
|
29
|
+
|
|
30
|
+
const lines = record.syncedLyrics
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((entry) => entry.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.filter(
|
|
35
|
+
(entry) => !entry.startsWith('[ar:') && !entry.startsWith('[ti:') && !entry.startsWith('[by:')
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const preview = [];
|
|
39
|
+
for (const rawLine of lines) {
|
|
40
|
+
const timestampMatches = rawLine.match(/(\[[0-9.:]+\])/g);
|
|
41
|
+
if (!timestampMatches) continue;
|
|
42
|
+
|
|
43
|
+
const text = rawLine.replace(/(\[[0-9.:]+\])/g, '').trim();
|
|
44
|
+
const timestamps = timestampMatches.slice(0, 2);
|
|
45
|
+
timestamps.forEach((timestamp) => {
|
|
46
|
+
if (preview.length < 2) {
|
|
47
|
+
preview.push(text ? `${timestamp} ${text}` : timestamp);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
if (preview.length >= 2) break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return truncatePreview(preview.join(' | '));
|
|
54
|
+
}
|