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.
@@ -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. or remix, or any other similar differentiation, put it at the right before (Lyrics) in Parentheses.
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: `John Wick, BANKS - This Song Is Lit`
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
- - Use the Spotify song lookup tool.
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
- Always fill value as true or 1, if there's a problem with input, do not input anything.
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
- Use the `build_catalog_payload` tool to resolve lyrics for each song.
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
- Call it with:
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` — a short slug identifying the cached lyrics server-side
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 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.
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. Those tools cannot handle long multiline lyric text without JSON truncation errors.
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
- **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.
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 lyrics text itself in `fields`. Do NOT include `lyricsFieldId` in `fields`.
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
- ### Phase 1 Resolve all data in parallel
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. Resolve Airtable destination info (`search_bases`, `list_tables_for_base`) do this once per base/table, not per song.
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. Resolve lyrics (`build_catalog_payload` with `preferRomanized: true`) for each song. Save each song's `lyricsCacheKey`.
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
- ### Phase 2 Bulk create/update records (Song (Video), Artists, Listen Link, and Ready for Generation fields only)
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
- Use `create_records_for_table` (or `update_records_for_table` if updating existing records) to write **Song (Video)**, **Artists**, **Listen Link**, and **Ready for Generation** fields for all songs in the batch. use typecast: true, so Artists can successfully be inserted.
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` returned for each newly created record you will need these in Phase 3.
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
- ### Phase 3Write lyrics for each record via push_catalog_to_airtable
253
+ ### Third passpopulate lyrics for each record via push_catalog_to_airtable
172
254
 
173
- For each record created in Phase 2, call `push_catalog_to_airtable` with:
255
+ For each target record established in the first pass, resolve lyrics and then call `push_catalog_to_airtable` with:
174
256
 
175
- - The `recordId` from Phase 2
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 Phase 1 for that song
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
- ### Phase 4SRT export
274
+ ### Fourth passpush 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 inserts succeed, the agent must export synced lyrics as `.SRT`.
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) | Bulk? |
266
- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | --------------------- |
267
- | Find base/table | `search_bases`, `list_tables_for_base` (Airtable MCP) | Once per base |
268
- | Spotify link | `search-spotify` (Spotify MCP) | Per song |
269
- | Lyrics resolution | `build_catalog_payload` (mr-magic) | Per song |
270
- | **(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** |
271
- | **Lyrics write** | **`push_catalog_to_airtable` (mr-magic) always** | Per song |
272
- | SRT export | `export_lyrics` (mr-magic) | Per song |
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
- Always use `push_catalog_to_airtable` for Lyrics no exceptions.
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 { fetchFromGenius, searchGenius, checkGeniusTokenReady } from './providers/genius.js';
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
+ }
@@ -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 exactMatch = results.find((record) => {
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
- const result = exactMatch ?? results[0];
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: Boolean(exactMatch),
58
- title: result.title,
59
- artist: result.artist
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) {
@@ -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
- try {
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
  }