mr-magic-mcp-server 0.3.9 → 0.3.11
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 +36 -26
- package/README.md +166 -13
- package/package.json +9 -7
- package/prompts/airtable-song-importer.md +143 -34
- package/src/index.js +89 -3
- package/src/provider-result-schema.js +88 -0
- package/src/providers/lrclib.js +25 -6
- package/src/providers/melon.js +46 -10
- package/src/services/lyrics-service.js +4 -0
- package/src/tests/mcp-tools.test.js +157 -2
- package/src/transport/mcp-tools.js +297 -52
|
@@ -9,11 +9,13 @@ The user will provide:
|
|
|
9
9
|
|
|
10
10
|
If you are not sure you have all required information, ask the user before proceeding.
|
|
11
11
|
|
|
12
|
+
Once the user has provided enough initial information to identify the Airtable destination and process the requested songs, continue through the main run autonomously. Do **not** stop between passes to ask the user for permission, confirmation, or manual intervention unless a required dependency is missing or a hard failure blocks progress.
|
|
13
|
+
|
|
12
14
|
For each batch of songs in the user's list, follow this workflow.
|
|
13
15
|
|
|
14
16
|
## 1) Resolve Airtable destination
|
|
15
17
|
|
|
16
|
-
Use Airtable tools to determine the correct:
|
|
18
|
+
Use your Airtable tools to determine the correct:
|
|
17
19
|
|
|
18
20
|
- base ID
|
|
19
21
|
- table ID
|
|
@@ -22,6 +24,11 @@ Use Airtable tools to determine the correct:
|
|
|
22
24
|
|
|
23
25
|
Always verify field IDs against field names before inserting or updating records.
|
|
24
26
|
|
|
27
|
+
Recommended tools for finding base IDs and table IDs:
|
|
28
|
+
search_bases
|
|
29
|
+
list_bases
|
|
30
|
+
list_tables_for_base
|
|
31
|
+
|
|
25
32
|
## 2) Required Airtable fields
|
|
26
33
|
|
|
27
34
|
For every song, populate only these Airtable fields:
|
|
@@ -39,14 +46,15 @@ Never send extra metadata or unused fields to Airtable.
|
|
|
39
46
|
`Song (Video)` must always be formatted exactly as:
|
|
40
47
|
`{Artist 1}, {Artist 2} - {Title} (Lyrics)`
|
|
41
48
|
|
|
42
|
-
If the song title has featuring, ft., feat
|
|
49
|
+
If the song title has featuring, ft., feat, take that out.
|
|
50
|
+
If the song has remix, or any other differentiation, put it at the right before (Lyrics) in Parentheses.
|
|
43
51
|
|
|
44
52
|
Examples:
|
|
45
53
|
|
|
46
54
|
- `BLACKPINK, Doja Cat, Absolutely - Crazy (Lyrics)`
|
|
47
55
|
- `Joji - Glimpse of Us (Lyrics)`
|
|
48
56
|
- `[GANG$] - Money (Remix) (Lyrics)`
|
|
49
|
-
- 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`
|
|
50
58
|
|
|
51
59
|
Artist names may contain brackets or special characters. Preserve them exactly.
|
|
52
60
|
|
|
@@ -59,28 +67,52 @@ Artist names may contain brackets or special characters. Preserve them exactly.
|
|
|
59
67
|
|
|
60
68
|
## 5) Listen Link rules
|
|
61
69
|
|
|
62
|
-
-
|
|
70
|
+
- You may only use your Spotify song lookup tool from `s4168377_get_spotify_song` (Make.com) or `search-spotify` (Spotify MCP) to find the links to fill in the entries.
|
|
71
|
+
- Do NOT try to search for links via `search_provider` or `search_lyrics`; those are lyrics-search tools only.
|
|
72
|
+
- `search_provider` / `search_lyrics` return preview-only lyric candidates plus reusable `reference` objects. They do **not** return Spotify links, full lyrics, or raw provider payloads.
|
|
63
73
|
- Use the `URL` value for the Airtable `Listen Link` field.
|
|
64
74
|
- Spotify link resolution must always be handled separately from lyric resolution.
|
|
75
|
+
- Use the titles provided exactly unless the user asks you to find an alternate version.
|
|
76
|
+
- When multiple releases exist, use the most popular/official upload for the exact title user provides.
|
|
65
77
|
|
|
66
78
|
## 6) Ready for Generation rules
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
- Wait until lyrics and artist fields have been fully populated, then run a ready for generation update pass. If you attempt to fill them all at once, the automation in the table will fail. This may only be set after there is content in both Lyrics and Artists.
|
|
81
|
+
- Always fill value as true or 1, if there's a problem with input, do not input anything.
|
|
82
|
+
- Again, procure lyrics, fill in all metadata, and once fully filled out, then you may send the ready for generation value.
|
|
69
83
|
|
|
70
84
|
## 7) Lyrics resolution rules
|
|
71
85
|
|
|
72
|
-
|
|
86
|
+
`build_catalog_payload` is the **required and exclusive lyric-resolution / lyric-preparation step for any Airtable entry**.
|
|
87
|
+
|
|
88
|
+
For every song that will be written to Airtable:
|
|
89
|
+
|
|
90
|
+
1. You **must** call `build_catalog_payload` before the Lyrics field can be written.
|
|
91
|
+
2. You may call `build_catalog_payload` either:
|
|
92
|
+
- directly with track metadata
|
|
93
|
+
- or with a previously selected `match` / reusable `reference` from `search_lyrics` or `search_provider`
|
|
94
|
+
3. You **must** keep the returned `lyricsCacheKey` and use that exact value later with `push_catalog_to_airtable`.
|
|
95
|
+
|
|
96
|
+
If you need to inspect lyric candidates before resolving one exactly:
|
|
73
97
|
|
|
74
|
-
|
|
98
|
+
1. Use `search_lyrics` (all providers) or `search_provider` (one provider) to get preview-only candidates and reusable references.
|
|
99
|
+
2. Use `select_match` if you need to choose one candidate from grouped `items`, flat `matches`, or a direct `match`.
|
|
100
|
+
3. Pass the selected `match` or `reference` into `build_catalog_payload`.
|
|
101
|
+
|
|
102
|
+
`search_lyrics`, `search_provider`, and `select_match` are **optional helpers for choosing the exact song**. They are **not replacements** for `build_catalog_payload`, and they do **not** complete Airtable lyric preparation by themselves.
|
|
103
|
+
|
|
104
|
+
Do **not** assume the search tools return full lyrics or raw provider payloads. They return previews plus reusable references only.
|
|
105
|
+
|
|
106
|
+
Call `build_catalog_payload` with:
|
|
75
107
|
|
|
76
108
|
- `preferRomanized: true`
|
|
77
109
|
|
|
78
110
|
The response will include:
|
|
79
111
|
|
|
80
|
-
- `lyricsCacheKey` —
|
|
112
|
+
- `lyricsCacheKey` — the cache key that must later be passed to `push_catalog_to_airtable`
|
|
81
113
|
- `songVideoTitle` — use this to cross-check the `Song (Video)` field formatting
|
|
82
114
|
|
|
83
|
-
Do **not** copy
|
|
115
|
+
Do **not** copy lyric text out of `build_catalog_payload`. `build_catalog_payload` prepares the cached lyric payload server-side; it does **not** write Airtable lyrics by itself. The actual Lyrics-field write happens later through `push_catalog_to_airtable` using the returned `lyricsCacheKey`. Never relay lyric text through tool-call arguments.
|
|
84
116
|
|
|
85
117
|
### Lyric priority (handled automatically by push_catalog_to_airtable)
|
|
86
118
|
|
|
@@ -89,11 +121,26 @@ Do **not** copy any lyric text out of `build_catalog_payload`. The full lyrics a
|
|
|
89
121
|
|
|
90
122
|
## 8) Airtable record write rules
|
|
91
123
|
|
|
124
|
+
Create new records by default, or update existing ones if they already exist.
|
|
125
|
+
|
|
126
|
+
Split Airtable writing responsibilities exactly as follows:
|
|
127
|
+
|
|
128
|
+
- `create_records_for_table` / `update_records_for_table` are for **Song (Video)**, **Artists**, **Listen Link**, and **Ready for Generation** only.
|
|
129
|
+
- `push_catalog_to_airtable` is for the **Lyrics** field only.
|
|
130
|
+
|
|
92
131
|
Use Airtable MCP tools (`create_records_for_table` / `update_records_for_table`) for **Song (Video)**, **Artists**, **Listen Link**, and **Ready for Generation** fields. These tools support bulk writes of up to 10 records per call — use that fully.
|
|
93
132
|
|
|
94
|
-
**STRICTLY FORBIDDEN:** Never use `create_records_for_table` or `update_records_for_table` to write or update the `Lyrics` field.
|
|
133
|
+
**STRICTLY FORBIDDEN:** Never use `create_records_for_table` or `update_records_for_table` to write, carry, relay, or update lyric text for the `Lyrics` field. Never place lyric text into those tool arguments. Never include the `Lyrics` field in those writes.
|
|
134
|
+
|
|
135
|
+
**Always use `push_catalog_to_airtable` to write the Lyrics field.** This is the only tool that actually writes Lyrics into Airtable. It makes the Airtable API call server-side, resolves lyrics from the cached payload identified by `lyricsCacheKey`, and keeps lyric text out of your tool-call arguments.
|
|
136
|
+
|
|
137
|
+
The required chain is:
|
|
95
138
|
|
|
96
|
-
|
|
139
|
+
1. `build_catalog_payload` resolves/prepares the lyrics and returns `lyricsCacheKey`
|
|
140
|
+
2. `create_records_for_table` / `update_records_for_table` write the non-lyrics Airtable fields only
|
|
141
|
+
3. `push_catalog_to_airtable` writes the Lyrics field using the `lyricsCacheKey` from `build_catalog_payload`
|
|
142
|
+
|
|
143
|
+
Do **not** skip `build_catalog_payload`. Do **not** treat search tools, selection tools, or export tools as Airtable lyric-write tools.
|
|
97
144
|
|
|
98
145
|
### How to call push_catalog_to_airtable
|
|
99
146
|
|
|
@@ -104,10 +151,11 @@ Pass:
|
|
|
104
151
|
- `recordId` — the record ID returned from the create step (required to update the Lyrics field)
|
|
105
152
|
- `fields` — pass an **empty object `{}`** (no non-lyrics fields; those were already written in the create step)
|
|
106
153
|
- `lyricsFieldId` — the field ID for the Lyrics field
|
|
107
|
-
- `lyricsCacheKey` — the value returned by `build_catalog_payload`
|
|
154
|
+
- `lyricsCacheKey` — the value returned by `build_catalog_payload` (required source of lyric data for Airtable)
|
|
108
155
|
- `preferRomanized: true`
|
|
109
156
|
|
|
110
|
-
Do NOT include the
|
|
157
|
+
Do NOT include the lyric text itself in `fields`. Do NOT include `lyricsFieldId` in `fields`.
|
|
158
|
+
Do NOT call `push_catalog_to_airtable` without first obtaining `lyricsCacheKey` from `build_catalog_payload`.
|
|
111
159
|
|
|
112
160
|
### Example push_catalog_to_airtable call shape (lyrics-only update)
|
|
113
161
|
|
|
@@ -131,21 +179,55 @@ If the lyrics write fails, retry with `splitLyricsUpdate: true`. This updates th
|
|
|
131
179
|
|
|
132
180
|
Process all songs in the user's list together as a batch, not one at a time.
|
|
133
181
|
|
|
134
|
-
|
|
182
|
+
After the initial inputs are sufficient, execute the main processing flow without pausing for user intervention between passes unless a required dependency is missing or a hard failure prevents continuation.
|
|
183
|
+
|
|
184
|
+
The batch run must happen in **four explicit passes**, in this order:
|
|
185
|
+
|
|
186
|
+
1. **First pass — catalog pass:** ensure songs exist in the catalog by creating or identifying the target Airtable records.
|
|
187
|
+
2. **Second pass — normalization + Spotify pass:** normalize artist values and populate Spotify listen links.
|
|
188
|
+
3. **Third pass — lyrics pass:** populate Lyrics using the required `build_catalog_payload` → `push_catalog_to_airtable` chain.
|
|
189
|
+
4. **Fourth pass — Ready for Generation pass:** push Ready for Generation only after the prior data requirements are satisfied.
|
|
190
|
+
|
|
191
|
+
### First pass — ensure songs exist in the catalog / identify target Airtable records
|
|
192
|
+
|
|
193
|
+
Before record writes, resolve shared Airtable destination data once per batch:
|
|
194
|
+
|
|
195
|
+
- resolve Airtable destination info (`search_bases`, `list_tables_for_base`)
|
|
196
|
+
- verify field IDs against field names
|
|
197
|
+
- resolve the view ID if available for final record links
|
|
198
|
+
|
|
199
|
+
Then, for every song in the batch:
|
|
200
|
+
|
|
201
|
+
- determine whether the song already exists and should be updated, or whether a new Airtable record must be created later
|
|
202
|
+
- identify any existing target `recordId` values that later passes will update
|
|
203
|
+
- prepare a per-song plan for `create` vs `update`, but keep this first pass **read-only**
|
|
204
|
+
|
|
205
|
+
Do **not** create new Airtable records in this first pass. If a song's Spotify lookup or lyric preparation later fails, you must avoid creating a new incomplete catalog row for that song.
|
|
206
|
+
|
|
207
|
+
This first pass is only for destination resolution, duplicate detection, and deciding which songs are safe to create later.
|
|
208
|
+
|
|
209
|
+
### Second pass — normalize artist values and populate Spotify listen links
|
|
135
210
|
|
|
136
211
|
For every song in the batch (up to all at once):
|
|
137
212
|
|
|
138
|
-
1.
|
|
213
|
+
1. Normalize artist values into the Airtable `Artists` input format required above.
|
|
139
214
|
2. Resolve Spotify link (`search-spotify`) for each song.
|
|
140
|
-
3.
|
|
215
|
+
3. Only after the non-lyrics metadata for a song is ready, write the non-lyrics Airtable fields needed for this pass:
|
|
216
|
+
- `Song (Video)`
|
|
217
|
+
- `Artists`
|
|
218
|
+
- `Listen Link`
|
|
219
|
+
4. For songs identified as existing in the first pass, use `update_records_for_table`.
|
|
220
|
+
5. For songs identified as new in the first pass, use `create_records_for_table` only now, after the non-lyrics metadata is ready.
|
|
221
|
+
|
|
222
|
+
Use `create_records_for_table` / `update_records_for_table` for these non-lyrics fields only, with `typecast: true` so Artists can successfully be inserted.
|
|
141
223
|
|
|
142
|
-
|
|
224
|
+
Do **not** include the `Lyrics` field in this pass. Do **not** include lyric text anywhere in these Airtable MCP tool arguments. Do **not** set `Ready for Generation` yet.
|
|
143
225
|
|
|
144
|
-
|
|
226
|
+
If a song cannot be resolved well enough to produce the required non-lyrics metadata for this pass, do **not** create a new Airtable record for that song.
|
|
145
227
|
|
|
146
228
|
- Send up to **10 records per call**.
|
|
147
229
|
- If the batch has more than 10 songs, split into multiple calls of up to 10 each.
|
|
148
|
-
- Capture the `recordId`
|
|
230
|
+
- Capture or preserve the `recordId` for each song — use the existing `recordId` identified in the first pass or the new `recordId` returned by the create step in this second pass.
|
|
149
231
|
|
|
150
232
|
Example batch create body (Song (Video), Artists, Listen Link, and Ready for Generation fields only — no Lyrics):
|
|
151
233
|
|
|
@@ -168,23 +250,46 @@ Example batch create body (Song (Video), Artists, Listen Link, and Ready for Gen
|
|
|
168
250
|
}
|
|
169
251
|
```
|
|
170
252
|
|
|
171
|
-
###
|
|
253
|
+
### Third pass — populate lyrics for each record via push_catalog_to_airtable
|
|
172
254
|
|
|
173
|
-
For each record
|
|
255
|
+
For each target record established in the first pass, resolve lyrics and then call `push_catalog_to_airtable` with:
|
|
174
256
|
|
|
175
|
-
-
|
|
257
|
+
- required Airtable-preparation path: call `build_catalog_payload` with direct track metadata and `preferRomanized: true`
|
|
258
|
+
- optional search-first helper path: call `search_lyrics` or `search_provider`, then `select_match` if needed, then call `build_catalog_payload` with the chosen `match` or `reference`
|
|
259
|
+
- save each song's `lyricsCacheKey`
|
|
260
|
+
- do **not** treat `search_lyrics`, `search_provider`, or `select_match` as completing Airtable lyric preparation; `build_catalog_payload` is still required
|
|
261
|
+
|
|
262
|
+
Then call `push_catalog_to_airtable` with:
|
|
263
|
+
|
|
264
|
+
- The `recordId` from the first pass
|
|
176
265
|
- `fields: {}` (non-lyrics fields already written)
|
|
177
266
|
- `lyricsFieldId` for the Lyrics field
|
|
178
|
-
- `lyricsCacheKey` from
|
|
267
|
+
- `lyricsCacheKey` from `build_catalog_payload` for that song
|
|
179
268
|
- `preferRomanized: true`
|
|
180
269
|
|
|
270
|
+
This is the **only Airtable lyric-write step**.
|
|
271
|
+
|
|
181
272
|
**One `push_catalog_to_airtable` call per song** (lyrics are per-track, not batchable).
|
|
182
273
|
|
|
183
|
-
###
|
|
274
|
+
### Fourth pass — push Ready for Generation
|
|
275
|
+
|
|
276
|
+
Only after the earlier passes have succeeded enough to satisfy the table automation requirements, run a final Ready for Generation update pass.
|
|
277
|
+
|
|
278
|
+
In this pass:
|
|
279
|
+
|
|
280
|
+
- update `Ready for Generation` to `true` or `1`
|
|
281
|
+
- do this only for records whose `Artists` field is populated and whose `Lyrics` field has already been written
|
|
282
|
+
- never combine this with the lyric-write step
|
|
283
|
+
|
|
284
|
+
This fourth pass must happen after the catalog pass, the normalization + Spotify pass, and the lyrics pass.
|
|
285
|
+
|
|
286
|
+
### Post-pass export step — SRT export
|
|
184
287
|
|
|
185
288
|
After all Airtable inserts and lyrics writes succeed:
|
|
186
289
|
|
|
187
290
|
- Export `.SRT` lyrics using the `export_lyrics` tool for each song.
|
|
291
|
+
- `export_lyrics` may be called with direct track metadata, or with the same selected `match` / `reference` used earlier so the export resolves the exact same lyric result.
|
|
292
|
+
- `export_lyrics` is a post-Airtable export step only. It is **not** part of Airtable lyric insertion and must never be described as the tool that writes Lyrics into Airtable.
|
|
188
293
|
- Confirm the user has received at least one of the following:
|
|
189
294
|
- SRT download link
|
|
190
295
|
- SRT file path
|
|
@@ -193,7 +298,7 @@ After all Airtable inserts and lyrics writes succeed:
|
|
|
193
298
|
|
|
194
299
|
## 10) Required export step after Airtable succeeds
|
|
195
300
|
|
|
196
|
-
After all Airtable
|
|
301
|
+
After all Airtable passes succeed, the agent must export synced lyrics as `.SRT`.
|
|
197
302
|
This export step is required and must be handled separately from Airtable insertion.
|
|
198
303
|
|
|
199
304
|
### Export priority
|
|
@@ -224,10 +329,12 @@ If the export backend does not provide a downloadable file or writable folder, t
|
|
|
224
329
|
|
|
225
330
|
Use the `export_lyrics` tool to export `.SRT` output after Airtable insertion is complete.
|
|
226
331
|
Prefer synced romanized output when available; otherwise use synced lyrics.
|
|
332
|
+
If you already selected a lyric candidate via `search_lyrics` / `search_provider`, reuse that `match` or `reference` in `export_lyrics` for exact-result recall.
|
|
227
333
|
Do not confuse Airtable lyric insertion with export output:
|
|
228
334
|
|
|
229
335
|
- Airtable `Lyrics` must contain only plain-text lyrics (handled server-side)
|
|
230
336
|
- export output may be synced `.SRT`
|
|
337
|
+
- `export_lyrics` does **not** insert Airtable lyrics
|
|
231
338
|
|
|
232
339
|
If the tool returns a URL, present that URL clearly.
|
|
233
340
|
If the tool returns a file path, present that file path clearly.
|
|
@@ -262,14 +369,16 @@ If the view ID could not be resolved, omit it from the URL rather than guessing.
|
|
|
262
369
|
|
|
263
370
|
## 12) Tool responsibility summary
|
|
264
371
|
|
|
265
|
-
| Step | Tool (MCP Server)
|
|
266
|
-
| ------------------------------------------------------------------------------ |
|
|
267
|
-
| Find base/table | `search_bases`, `list_tables_for_base` (Airtable MCP)
|
|
268
|
-
| Spotify link | `search-spotify` (Spotify MCP)
|
|
269
|
-
|
|
|
270
|
-
|
|
|
271
|
-
| **
|
|
272
|
-
|
|
|
372
|
+
| Step | Tool (MCP Server) | Bulk? |
|
|
373
|
+
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------- |
|
|
374
|
+
| Find base/table | `search_bases`, `list_tables_for_base` (Airtable MCP) | Once per base |
|
|
375
|
+
| Spotify link | `s4168377_get_spotify_song` (Make.com) or `search-spotify` (Spotify MCP). | Per song |
|
|
376
|
+
| Optional lyric candidate preview / selection | `search_lyrics` / `search_provider`, then `select_match` (mr-magic) | Per song |
|
|
377
|
+
| Required Airtable lyric preparation | `build_catalog_payload` with track, `match`, or `reference` (mr-magic) | Per song |
|
|
378
|
+
| **(Song (Video), Artists, Listen Link, and Ready for Generation fields write** | **`create_records_for_table` / `update_records_for_table` (Airtable MCP)** | **Up to 10 per call** |
|
|
379
|
+
| **Lyrics write** | **`push_catalog_to_airtable` (mr-magic) — always, using `lyricsCacheKey` from `build_catalog_payload`** | Per song |
|
|
380
|
+
| Post-write SRT export | `export_lyrics` with track, `match`, or `reference` (mr-magic) | Per song |
|
|
273
381
|
|
|
274
382
|
**Never use `create_records_for_table` or `update_records_for_table` for the Lyrics field.**
|
|
275
|
-
|
|
383
|
+
**Never use `search_lyrics`, `search_provider`, `select_match`, or `export_lyrics` as substitutes for Airtable lyric preparation or Airtable lyric writing.**
|
|
384
|
+
Always use `build_catalog_payload` first, then use `push_catalog_to_airtable` for Lyrics — no exceptions.
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { fetchFromLrclib, searchLrclib } from './providers/lrclib.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fetchFromGenius,
|
|
4
|
+
searchGenius,
|
|
5
|
+
checkGeniusTokenReady,
|
|
6
|
+
fetchLyricsForGeniusSong
|
|
7
|
+
} from './providers/genius.js';
|
|
3
8
|
import {
|
|
4
9
|
fetchFromMusixmatch,
|
|
5
10
|
searchMusixmatch,
|
|
6
11
|
checkMusixmatchTokenReady
|
|
7
12
|
} from './providers/musixmatch.js';
|
|
8
|
-
import { fetchFromMelon, searchMelon } from './providers/melon.js';
|
|
13
|
+
import { fetchFromMelon, searchMelon, fetchMelonBySongId } from './providers/melon.js';
|
|
9
14
|
import { getEnvValue } from './utils/config.js';
|
|
10
|
-
import { lyricContentScore } from './provider-result-schema.js';
|
|
15
|
+
import { buildProviderReferenceFingerprint, lyricContentScore } from './provider-result-schema.js';
|
|
11
16
|
|
|
12
17
|
const providers = [
|
|
13
18
|
{ name: 'lrclib', fetch: fetchFromLrclib, search: searchLrclib },
|
|
@@ -121,6 +126,87 @@ export async function searchProvider(providerName, track) {
|
|
|
121
126
|
return provider.search(track);
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
function buildLookupTrack(track = {}, reference = {}) {
|
|
130
|
+
return {
|
|
131
|
+
title: track.title || reference.title || '',
|
|
132
|
+
artist: track.artist || reference.artist || '',
|
|
133
|
+
album: track.album || reference.album || null,
|
|
134
|
+
duration: track.duration ?? reference.duration ?? null
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function recordMatchesReference(record, reference = {}) {
|
|
139
|
+
if (!record || !reference) return false;
|
|
140
|
+
|
|
141
|
+
if (reference.providerId && record.providerId === reference.providerId.toString()) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const referenceIds = reference.ids || {};
|
|
146
|
+
const recordIds = record.ids || {};
|
|
147
|
+
if (
|
|
148
|
+
Object.entries(referenceIds).some(
|
|
149
|
+
([key, value]) => value !== null && value !== undefined && recordIds[key] === value.toString()
|
|
150
|
+
)
|
|
151
|
+
) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (reference.sourceUrl && record.sourceUrl === reference.sourceUrl) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (reference.fingerprint) {
|
|
160
|
+
return buildProviderReferenceFingerprint(record) === reference.fingerprint;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function resolveProviderReference(reference, track = {}) {
|
|
167
|
+
if (!reference?.provider) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lookupTrack = buildLookupTrack(track, reference);
|
|
172
|
+
|
|
173
|
+
if (reference.provider === 'melon') {
|
|
174
|
+
const songId = reference.ids?.songId || reference.providerId;
|
|
175
|
+
return fetchMelonBySongId(songId, lookupTrack);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (reference.provider === 'lrclib') {
|
|
179
|
+
const results = await searchLrclib(lookupTrack);
|
|
180
|
+
return results.find((record) => recordMatchesReference(record, reference)) ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (reference.provider === 'musixmatch') {
|
|
184
|
+
const results = await searchMusixmatch(lookupTrack);
|
|
185
|
+
return results.find((record) => recordMatchesReference(record, reference)) ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (reference.provider === 'genius') {
|
|
189
|
+
const results = await searchGenius(lookupTrack);
|
|
190
|
+
const exact = results.find((record) => recordMatchesReference(record, reference)) ?? null;
|
|
191
|
+
if (!exact) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (exact.sourceUrl) {
|
|
195
|
+
try {
|
|
196
|
+
const plainLyrics = await fetchLyricsForGeniusSong(exact.sourceUrl);
|
|
197
|
+
if (plainLyrics) {
|
|
198
|
+
exact.plainLyrics = plainLyrics;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Best effort hydration; return the exact metadata match even when scraping fails.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return exact;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
124
210
|
export function selectMatch(matches, { providerName, requireSynced = false } = {}) {
|
|
125
211
|
const filtered = providerName
|
|
126
212
|
? matches.filter((match) => match.provider === providerName)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
export function detectSyncedState(syncedLyrics) {
|
|
2
4
|
if (!syncedLyrics) return { hasSynced: false, timestampCount: 0 };
|
|
3
5
|
const lines = syncedLyrics
|
|
@@ -56,6 +58,40 @@ export function lyricContentScore(record) {
|
|
|
56
58
|
return score;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
function compactIds(entries) {
|
|
62
|
+
const ids = Object.fromEntries(
|
|
63
|
+
entries
|
|
64
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
|
65
|
+
.map(([key, value]) => [key, value.toString()])
|
|
66
|
+
);
|
|
67
|
+
return Object.keys(ids).length > 0 ? ids : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractProviderIds(provider, raw = null, providerId = null) {
|
|
71
|
+
switch (provider) {
|
|
72
|
+
case 'melon':
|
|
73
|
+
return compactIds([['songId', raw?.songId ?? providerId]]);
|
|
74
|
+
case 'genius':
|
|
75
|
+
return compactIds([
|
|
76
|
+
['songId', raw?.id ?? providerId],
|
|
77
|
+
['apiPath', raw?.api_path],
|
|
78
|
+
['iq', raw?.iq ?? raw?.stats?.iq]
|
|
79
|
+
]);
|
|
80
|
+
case 'musixmatch':
|
|
81
|
+
return compactIds([
|
|
82
|
+
[
|
|
83
|
+
'trackId',
|
|
84
|
+
raw?.['matcher.track.get']?.message?.body?.track?.track_id ?? raw?.track_id ?? providerId
|
|
85
|
+
],
|
|
86
|
+
['commontrackId', raw?.['matcher.track.get']?.message?.body?.track?.commontrack_id]
|
|
87
|
+
]);
|
|
88
|
+
case 'lrclib':
|
|
89
|
+
return compactIds([['trackId', raw?.id ?? providerId]]);
|
|
90
|
+
default:
|
|
91
|
+
return compactIds([['id', providerId]]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
59
95
|
export function normalizeLyricRecord({
|
|
60
96
|
provider,
|
|
61
97
|
id,
|
|
@@ -77,6 +113,7 @@ export function normalizeLyricRecord({
|
|
|
77
113
|
return {
|
|
78
114
|
provider,
|
|
79
115
|
providerId: id?.toString() ?? null,
|
|
116
|
+
ids: extractProviderIds(provider, raw, id),
|
|
80
117
|
title: trackName || null,
|
|
81
118
|
artist: artistName || null,
|
|
82
119
|
album: albumName || null,
|
|
@@ -102,3 +139,54 @@ export function recomputeSyncFlags(record) {
|
|
|
102
139
|
record.timestampCount = timestampCount;
|
|
103
140
|
return record;
|
|
104
141
|
}
|
|
142
|
+
|
|
143
|
+
function normalizeFingerprintValue(value) {
|
|
144
|
+
if (value === null || value === undefined) return '';
|
|
145
|
+
return value.toString().trim().replace(/\s+/g, ' ');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compactFingerprintIds(ids = {}) {
|
|
149
|
+
return Object.entries(ids)
|
|
150
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
|
151
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
152
|
+
.map(([key, value]) => `${key}:${normalizeFingerprintValue(value)}`)
|
|
153
|
+
.join('|');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildFingerprintSnippet(record = {}) {
|
|
157
|
+
const lyricText = [
|
|
158
|
+
record.plainLyrics,
|
|
159
|
+
record.syncedLyrics,
|
|
160
|
+
record.plainPreview,
|
|
161
|
+
record.syncedPreview
|
|
162
|
+
]
|
|
163
|
+
.map((value) => normalizeFingerprintValue(value))
|
|
164
|
+
.find(Boolean);
|
|
165
|
+
return lyricText ? lyricText.slice(0, 100) : '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function buildProviderReferenceFingerprint(record = {}) {
|
|
169
|
+
if (!record || typeof record !== 'object') {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const provider = normalizeFingerprintValue(record.provider);
|
|
174
|
+
const providerId = normalizeFingerprintValue(record.providerId);
|
|
175
|
+
const ids = compactFingerprintIds(record.ids || {});
|
|
176
|
+
const sourceUrl = normalizeFingerprintValue(record.sourceUrl);
|
|
177
|
+
const title = normalizeFingerprintValue(record.title);
|
|
178
|
+
const artist = normalizeFingerprintValue(record.artist);
|
|
179
|
+
const album = normalizeFingerprintValue(record.album);
|
|
180
|
+
const duration = normalizeFingerprintValue(record.duration);
|
|
181
|
+
const snippet = !providerId && !ids && !sourceUrl ? buildFingerprintSnippet(record) : '';
|
|
182
|
+
|
|
183
|
+
const source = [provider, providerId, ids, sourceUrl, title, artist, album, duration, snippet]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join('||');
|
|
186
|
+
|
|
187
|
+
if (!source) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return createHash('sha256').update(source).digest('hex');
|
|
192
|
+
}
|
package/src/providers/lrclib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
|
|
3
|
-
import { normalizeLyricRecord } from '../provider-result-schema.js';
|
|
3
|
+
import { normalizeLyricRecord, lyricContentScore } from '../provider-result-schema.js';
|
|
4
4
|
import { createLogger } from '../utils/logger.js';
|
|
5
5
|
|
|
6
6
|
const logger = createLogger('provider:lrclib');
|
|
@@ -40,6 +40,22 @@ async function querySearch(track) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Pick the best candidate from a list: prefer synced over plain, then prefer
|
|
45
|
+
* richer lyric content. This ensures fetchFromLrclib always returns a synced
|
|
46
|
+
* result when one is available rather than the first incidentally-ordered one.
|
|
47
|
+
*/
|
|
48
|
+
function chooseBestCandidate(candidates) {
|
|
49
|
+
if (!candidates.length) return null;
|
|
50
|
+
return candidates.slice().sort((a, b) => {
|
|
51
|
+
// Synced results come first
|
|
52
|
+
const syncedDiff = (b.synced ? 1 : 0) - (a.synced ? 1 : 0);
|
|
53
|
+
if (syncedDiff !== 0) return syncedDiff;
|
|
54
|
+
// Among equally-synced results, prefer richer content
|
|
55
|
+
return lyricContentScore(b) - lyricContentScore(a);
|
|
56
|
+
})[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
export async function fetchFromLrclib(track) {
|
|
44
60
|
try {
|
|
45
61
|
const results = await querySearch(track);
|
|
@@ -47,16 +63,19 @@ export async function fetchFromLrclib(track) {
|
|
|
47
63
|
logger.debug('LRCLIB: no results found', { track });
|
|
48
64
|
return null;
|
|
49
65
|
}
|
|
50
|
-
const
|
|
66
|
+
const exactMatches = results.filter((record) => {
|
|
51
67
|
const sameTitle = record.title?.toLowerCase() === track.title?.toLowerCase();
|
|
52
68
|
const sameArtist = record.artist?.toLowerCase() === track.artist?.toLowerCase();
|
|
53
69
|
return sameTitle && sameArtist;
|
|
54
70
|
});
|
|
55
|
-
|
|
71
|
+
// Prefer exact matches; if none, fall back to all results.
|
|
72
|
+
// Within each group, prefer synced then richer content.
|
|
73
|
+
const result = chooseBestCandidate(exactMatches) ?? chooseBestCandidate(results);
|
|
56
74
|
logger.debug('LRCLIB: match selected', {
|
|
57
|
-
exact:
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
exact: exactMatches.length > 0,
|
|
76
|
+
synced: result?.synced,
|
|
77
|
+
title: result?.title,
|
|
78
|
+
artist: result?.artist
|
|
60
79
|
});
|
|
61
80
|
return result;
|
|
62
81
|
} catch (error) {
|
package/src/providers/melon.js
CHANGED
|
@@ -163,6 +163,46 @@ function toLyricStrings(lyricInfo) {
|
|
|
163
163
|
return { plain: plain || null, synced: null };
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
export async function fetchMelonBySongId(songId, track = {}) {
|
|
167
|
+
const normalizedSongId = songId?.toString().trim();
|
|
168
|
+
if (!normalizedSongId) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const record = normalizeLyricRecord({
|
|
173
|
+
provider: 'melon',
|
|
174
|
+
id: normalizedSongId,
|
|
175
|
+
trackName: track.title || null,
|
|
176
|
+
artistName: track.artist || null,
|
|
177
|
+
albumName: track.album || null,
|
|
178
|
+
duration: null,
|
|
179
|
+
plainLyrics: null,
|
|
180
|
+
syncedLyrics: null,
|
|
181
|
+
sourceUrl: `https://www.melon.com/song/detail.htm?songId=${normalizedSongId}`,
|
|
182
|
+
confidence: 0.5,
|
|
183
|
+
synced: false,
|
|
184
|
+
status: 'ok',
|
|
185
|
+
raw: {
|
|
186
|
+
songId: normalizedSongId,
|
|
187
|
+
title: track.title || null,
|
|
188
|
+
artist: track.artist || null,
|
|
189
|
+
album: track.album || null
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const lyricInfo = await fetchLyricInfo(normalizedSongId);
|
|
195
|
+
const { plain, synced } = toLyricStrings(lyricInfo);
|
|
196
|
+
record.plainLyrics = plain;
|
|
197
|
+
record.syncedLyrics = synced;
|
|
198
|
+
recomputeSyncFlags(record);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
logger.error('Melon lyricInfo request failed', { error, songId: normalizedSongId });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return record;
|
|
204
|
+
}
|
|
205
|
+
|
|
166
206
|
export async function searchMelon(track) {
|
|
167
207
|
const pageHtml = await fetchSearchPage(track);
|
|
168
208
|
return parseSearchPage(pageHtml).map((record) =>
|
|
@@ -185,17 +225,13 @@ export async function searchMelon(track) {
|
|
|
185
225
|
}
|
|
186
226
|
|
|
187
227
|
export async function fetchFromMelon(track) {
|
|
228
|
+
const requestedSongId = track?.songId || track?.providerId || track?.ids?.songId;
|
|
229
|
+
if (requestedSongId) {
|
|
230
|
+
return fetchMelonBySongId(requestedSongId, track);
|
|
231
|
+
}
|
|
232
|
+
|
|
188
233
|
const candidates = await searchMelon(track);
|
|
189
234
|
const primary = candidates[0];
|
|
190
235
|
if (!primary) return null;
|
|
191
|
-
|
|
192
|
-
const lyricInfo = await fetchLyricInfo(primary.providerId);
|
|
193
|
-
const { plain, synced } = toLyricStrings(lyricInfo);
|
|
194
|
-
primary.plainLyrics = plain;
|
|
195
|
-
primary.syncedLyrics = synced;
|
|
196
|
-
recomputeSyncFlags(primary);
|
|
197
|
-
} catch (error) {
|
|
198
|
-
logger.error('Melon lyricInfo request failed', { error, songId: primary.providerId });
|
|
199
|
-
}
|
|
200
|
-
return primary;
|
|
236
|
+
return fetchMelonBySongId(primary.providerId, primary);
|
|
201
237
|
}
|