mr-magic-mcp-server 0.3.3 → 0.3.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/package.json +1 -1
- package/src/core/find-service.js +38 -4
- package/src/index.js +25 -4
- package/src/provider-result-schema.js +41 -0
- package/src/services/lyrics-service.js +15 -0
- package/src/tests/mcp-search-find-verify.mjs +55 -0
- package/src/tests/run-tests.js +230 -1
- package/src/tools/cli.js +58 -15
- package/src/transport/mcp-tools.js +35 -7
package/package.json
CHANGED
package/src/core/find-service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { findLyrics, findSyncedLyrics, searchProvider, searchSources } from '../index.js';
|
|
2
|
+
import { lyricContentScore } from '../provider-result-schema.js';
|
|
2
3
|
|
|
3
4
|
export function normalizeTrack(track = {}) {
|
|
4
5
|
if (!track || typeof track !== 'object') {
|
|
@@ -54,13 +55,46 @@ export function pickIndex(entries, index) {
|
|
|
54
55
|
return entries[parsedIndex - 1].result;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Rank a chooser entry for auto-picking.
|
|
60
|
+
*
|
|
61
|
+
* Priority (highest first):
|
|
62
|
+
* 1. Has actual lyric content (score > 0) beats empty/unhydrated entries.
|
|
63
|
+
* 2. Synced beats plain when both have real content.
|
|
64
|
+
* 3. Richer content (more lyric lines) wins when both are non-empty.
|
|
65
|
+
* 4. Original list order is preserved as the final tie-breaker (lower index wins).
|
|
66
|
+
*/
|
|
67
|
+
function entryRankScore(entry, index) {
|
|
68
|
+
const result = entry?.result;
|
|
69
|
+
const content = lyricContentScore(result) * 10; // 0 or 5..10
|
|
70
|
+
const syncedBonus = result?.synced ? 0.5 : 0;
|
|
71
|
+
// Use a small negative index term so earlier list positions win ties
|
|
72
|
+
const positionPenalty = index * 0.0001;
|
|
73
|
+
return content + syncedBonus - positionPenalty;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export function autoPick(entries, preferSynced = true) {
|
|
58
77
|
if (!entries.length) return null;
|
|
78
|
+
|
|
79
|
+
// Sort a shallow copy by rank, descending
|
|
80
|
+
const ranked = entries
|
|
81
|
+
.map((entry, idx) => ({ entry, rank: entryRankScore(entry, idx) }))
|
|
82
|
+
.sort((a, b) => b.rank - a.rank);
|
|
83
|
+
|
|
59
84
|
if (preferSynced) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
// Prefer synced among results that actually have content
|
|
86
|
+
const syncedWithContent = ranked.find(
|
|
87
|
+
({ entry }) => entry.result?.synced && lyricContentScore(entry.result) > 0
|
|
88
|
+
);
|
|
89
|
+
if (syncedWithContent) {
|
|
90
|
+
return syncedWithContent.entry.result;
|
|
91
|
+
}
|
|
92
|
+
// Fall back to synced without content check (legacy behaviour preserved)
|
|
93
|
+
const anySynced = ranked.find(({ entry }) => entry.result?.synced);
|
|
94
|
+
if (anySynced) {
|
|
95
|
+
return anySynced.entry.result;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
|
-
|
|
98
|
+
|
|
99
|
+
return ranked[0].entry.result;
|
|
66
100
|
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from './providers/musixmatch.js';
|
|
8
8
|
import { fetchFromMelon, searchMelon } from './providers/melon.js';
|
|
9
9
|
import { getEnvValue } from './utils/config.js';
|
|
10
|
+
import { lyricContentScore } from './provider-result-schema.js';
|
|
10
11
|
|
|
11
12
|
const providers = [
|
|
12
13
|
{ name: 'lrclib', fetch: fetchFromLrclib, search: searchLrclib },
|
|
@@ -19,10 +20,24 @@ const providerIndex = providers.reduce(
|
|
|
19
20
|
new Map()
|
|
20
21
|
);
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Score a candidate result for ranking.
|
|
25
|
+
*
|
|
26
|
+
* Priority order (highest wins):
|
|
27
|
+
* 1. Results with actual lyric content beat empty / unhydrated ones.
|
|
28
|
+
* lyricContentScore returns 0 for empties, 0.5-1.0 for results that have text,
|
|
29
|
+
* with a continuous richness bonus proportional to the line count.
|
|
30
|
+
* 2. Among results that both have lyric text, synced content beats plain.
|
|
31
|
+
* 3. Provider confidence score is a secondary tie-breaker.
|
|
32
|
+
*
|
|
33
|
+
* Multiplying lyricContentScore by 10 ensures it always dominates the confidence
|
|
34
|
+
* score (which lives in the 0-1 range) and the synced bonus (0.5).
|
|
35
|
+
*/
|
|
22
36
|
function rankRecord(record) {
|
|
23
|
-
const
|
|
37
|
+
const contentScore = lyricContentScore(record) * 10; // 0 or 5..10
|
|
24
38
|
const syncedBonus = record?.synced ? 0.5 : 0;
|
|
25
|
-
|
|
39
|
+
const confidenceScore = record?.confidence ?? 0;
|
|
40
|
+
return contentScore + syncedBonus + confidenceScore;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
async function tryProviders(track, { syncedOnly = false, providerNames = [] } = {}) {
|
|
@@ -59,7 +74,11 @@ async function tryProviders(track, { syncedOnly = false, providerNames = [] } =
|
|
|
59
74
|
export async function findLyrics(track, options = {}) {
|
|
60
75
|
const { matches, best } = await tryProviders(track, options);
|
|
61
76
|
return {
|
|
62
|
-
|
|
77
|
+
// Filter out candidates with no actual lyric content (empty / unhydrated stubs).
|
|
78
|
+
// This keeps the matches list clean for all consumers: CLI, MCP tools, HTTP server.
|
|
79
|
+
matches: matches
|
|
80
|
+
.filter(({ result }) => lyricContentScore(result) > 0)
|
|
81
|
+
.map(({ provider, result }) => ({ provider, result })),
|
|
63
82
|
best: best?.result ?? null
|
|
64
83
|
};
|
|
65
84
|
}
|
|
@@ -67,7 +86,9 @@ export async function findLyrics(track, options = {}) {
|
|
|
67
86
|
export async function findSyncedLyrics(track, options = {}) {
|
|
68
87
|
const { matches, best } = await tryProviders(track, { ...options, syncedOnly: true });
|
|
69
88
|
return {
|
|
70
|
-
matches: matches
|
|
89
|
+
matches: matches
|
|
90
|
+
.filter(({ result }) => lyricContentScore(result) > 0)
|
|
91
|
+
.map(({ provider, result }) => ({ provider, result })),
|
|
71
92
|
best: best?.result ?? null
|
|
72
93
|
};
|
|
73
94
|
}
|
|
@@ -15,6 +15,47 @@ export function detectSyncedState(syncedLyrics) {
|
|
|
15
15
|
return { hasSynced: timestampCount > 1, timestampCount };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Count non-empty lines in a lyric string, after stripping LRC timestamps.
|
|
20
|
+
* Used for ranking results by lyric richness.
|
|
21
|
+
*/
|
|
22
|
+
export function countLyricLines(text) {
|
|
23
|
+
if (!text || typeof text !== 'string') return 0;
|
|
24
|
+
return text
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.replace(/^\[\d{1,2}:\d{2}[.:]\d{1,3}\]/, '').trim())
|
|
27
|
+
.filter(Boolean).length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns a numeric "content score" for a lyric record:
|
|
32
|
+
* - 0 → no lyric text at all (empty / unhydrated)
|
|
33
|
+
* - 0.5 → has some lyric text (plain or synced)
|
|
34
|
+
* - 0…1 continuous bonus added for richness (normalized line count, capped at 1)
|
|
35
|
+
*
|
|
36
|
+
* The function only inspects the actual lyric strings so that placeholder
|
|
37
|
+
* records that happen to have `plainLyrics: ""` still score 0.
|
|
38
|
+
*/
|
|
39
|
+
export function lyricContentScore(record) {
|
|
40
|
+
if (!record) return 0;
|
|
41
|
+
|
|
42
|
+
const plainText = typeof record.plainLyrics === 'string' ? record.plainLyrics.trim() : '';
|
|
43
|
+
const syncedText = typeof record.syncedLyrics === 'string' ? record.syncedLyrics.trim() : '';
|
|
44
|
+
|
|
45
|
+
const hasAny = Boolean(plainText || syncedText);
|
|
46
|
+
if (!hasAny) return 0;
|
|
47
|
+
|
|
48
|
+
// Base score for having any content
|
|
49
|
+
let score = 0.5;
|
|
50
|
+
|
|
51
|
+
// Richness bonus: cap at 200 lines → bonus up to 0.5
|
|
52
|
+
const lines = Math.max(countLyricLines(plainText), countLyricLines(syncedText));
|
|
53
|
+
const richnessBonus = Math.min(lines / 200, 0.5);
|
|
54
|
+
score += richnessBonus;
|
|
55
|
+
|
|
56
|
+
return score;
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
export function normalizeLyricRecord({
|
|
19
60
|
provider,
|
|
20
61
|
id,
|
|
@@ -114,6 +114,21 @@ export async function buildPayloadFromResult(result, context) {
|
|
|
114
114
|
if (context.shouldExport) {
|
|
115
115
|
payload.exports = await exportBestResult(result, context);
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
// Populate the in-memory catalog cache so push_catalog_to_airtable can be
|
|
119
|
+
// used immediately after find_lyrics / find_synced_lyrics / export_lyrics —
|
|
120
|
+
// the agent does NOT have to call build_catalog_payload first.
|
|
121
|
+
const best = result.best;
|
|
122
|
+
const plainLyrics = payload.formatted?.plainLyrics || best.plainLyrics || '';
|
|
123
|
+
if (plainLyrics) {
|
|
124
|
+
const key = catalogCacheKey({ artist: best.artist, title: best.title });
|
|
125
|
+
catalogCache.set(key, {
|
|
126
|
+
plainLyrics,
|
|
127
|
+
romanizedPlainLyrics: payload.formatted?.romanizedPlain || null,
|
|
128
|
+
preferRomanized: context.includeRomanization
|
|
129
|
+
});
|
|
130
|
+
payload.lyricsCacheKey = key;
|
|
131
|
+
}
|
|
117
132
|
}
|
|
118
133
|
return payload;
|
|
119
134
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import '../utils/config.js';
|
|
3
|
+
import { handleMcpTool } from '../transport/mcp-tools.js';
|
|
4
|
+
|
|
5
|
+
const track = { artist: 'Dylan Cotrone', title: 'Cigarette' };
|
|
6
|
+
|
|
7
|
+
// ── find_lyrics ──────────────────────────────────────────────────────────────
|
|
8
|
+
console.log('=== find_lyrics ===');
|
|
9
|
+
const findResult = await handleMcpTool('find_lyrics', { track });
|
|
10
|
+
const matchSummary = (findResult.matches ?? []).map((m) => ({
|
|
11
|
+
provider: m.provider,
|
|
12
|
+
synced: Boolean(m.result?.synced),
|
|
13
|
+
hasPlain: Boolean(m.result?.plainLyrics?.trim()),
|
|
14
|
+
hasSynced: Boolean(m.result?.syncedLyrics?.trim()),
|
|
15
|
+
plainLines: (m.result?.plainLyrics || '').split('\n').filter(Boolean).length
|
|
16
|
+
}));
|
|
17
|
+
console.log(`matches (${matchSummary.length}):`);
|
|
18
|
+
matchSummary.forEach((m) => console.log(' ', JSON.stringify(m)));
|
|
19
|
+
console.log('best provider:', findResult.best?.provider ?? 'none');
|
|
20
|
+
console.log('best hasPlain:', Boolean(findResult.best?.plainLyrics?.trim()));
|
|
21
|
+
console.log('best hasSynced:', Boolean(findResult.best?.syncedLyrics?.trim()));
|
|
22
|
+
console.log('lyricsCacheKey:', findResult.lyricsCacheKey ?? '(none)');
|
|
23
|
+
|
|
24
|
+
const emptyMatches = matchSummary.filter((m) => !m.hasPlain && !m.hasSynced);
|
|
25
|
+
if (emptyMatches.length > 0) {
|
|
26
|
+
console.error('FAIL: find_lyrics returned empty-content matches:', emptyMatches);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
} else {
|
|
29
|
+
console.log('PASS: all matches have lyric content');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hasBestLyrics =
|
|
33
|
+
Boolean(findResult.best?.plainLyrics?.trim()) || Boolean(findResult.best?.syncedLyrics?.trim());
|
|
34
|
+
if (hasBestLyrics && !findResult.lyricsCacheKey) {
|
|
35
|
+
console.error('FAIL: find_lyrics resolved lyrics but did not return lyricsCacheKey');
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
} else if (hasBestLyrics) {
|
|
38
|
+
console.log('PASS: lyricsCacheKey present when lyrics resolved');
|
|
39
|
+
} else {
|
|
40
|
+
console.log('NOTE: no lyrics resolved, lyricsCacheKey expected to be absent');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── search_lyrics ─────────────────────────────────────────────────────────────
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log('=== search_lyrics (raw catalog stubs — no hydration by design) ===');
|
|
46
|
+
const searchResult = await handleMcpTool('search_lyrics', { track });
|
|
47
|
+
for (const entry of searchResult) {
|
|
48
|
+
const withContent = entry.results.filter(
|
|
49
|
+
(r) => (r.plainLyrics || '').trim() || (r.syncedLyrics || '').trim()
|
|
50
|
+
).length;
|
|
51
|
+
console.log(
|
|
52
|
+
` ${entry.provider}: ${entry.results.length} results, ${withContent} already have lyric text`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
console.log('NOTE: search_lyrics returns raw stubs intentionally — hydration is not applied here.');
|
package/src/tests/run-tests.js
CHANGED
|
@@ -3,7 +3,18 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
|
|
4
4
|
import { selectMatch } from '../index.js';
|
|
5
5
|
import { buildChooserEntries, autoPick } from '../core/find-service.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
normalizeLyricRecord,
|
|
8
|
+
detectSyncedState,
|
|
9
|
+
lyricContentScore,
|
|
10
|
+
countLyricLines
|
|
11
|
+
} from '../provider-result-schema.js';
|
|
12
|
+
import {
|
|
13
|
+
buildPayloadFromResult,
|
|
14
|
+
buildActionContext,
|
|
15
|
+
catalogCacheKey,
|
|
16
|
+
catalogCache
|
|
17
|
+
} from '../services/lyrics-service.js';
|
|
7
18
|
import { mcpToolDefinitions, handleMcpTool } from '../transport/mcp-tools.js';
|
|
8
19
|
|
|
9
20
|
const divider = () => console.log('\n---');
|
|
@@ -88,11 +99,229 @@ function testNormalizationDetectsSyncedState() {
|
|
|
88
99
|
console.log('normalize/detect synced state: ok');
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
function testLyricContentScoreEmpty() {
|
|
103
|
+
const empty = { plainLyrics: null, syncedLyrics: null };
|
|
104
|
+
assert.equal(lyricContentScore(empty), 0, 'empty record should score 0');
|
|
105
|
+
assert.equal(lyricContentScore(null), 0, 'null record should score 0');
|
|
106
|
+
const blankString = { plainLyrics: ' ', syncedLyrics: '' };
|
|
107
|
+
assert.equal(lyricContentScore(blankString), 0, 'whitespace-only record should score 0');
|
|
108
|
+
divider();
|
|
109
|
+
console.log('lyricContentScore empty records → 0: ok');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function testLyricContentScoreWithContent() {
|
|
113
|
+
const plain = { plainLyrics: 'line one\nline two\nline three', syncedLyrics: null };
|
|
114
|
+
const score = lyricContentScore(plain);
|
|
115
|
+
assert.ok(score > 0, 'record with plain lyrics should score > 0');
|
|
116
|
+
assert.ok(score >= 0.5, 'base score should be at least 0.5');
|
|
117
|
+
divider();
|
|
118
|
+
console.log('lyricContentScore with content > 0: ok');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function testLyricContentScoreRichness() {
|
|
122
|
+
const short = { plainLyrics: 'line one', syncedLyrics: null };
|
|
123
|
+
const longLines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
|
124
|
+
const long = { plainLyrics: longLines, syncedLyrics: null };
|
|
125
|
+
assert.ok(
|
|
126
|
+
lyricContentScore(long) > lyricContentScore(short),
|
|
127
|
+
'longer lyrics should score higher'
|
|
128
|
+
);
|
|
129
|
+
divider();
|
|
130
|
+
console.log('lyricContentScore richness (longer > shorter): ok');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function testCountLyricLines() {
|
|
134
|
+
assert.equal(countLyricLines(null), 0, 'null input → 0');
|
|
135
|
+
assert.equal(countLyricLines(''), 0, 'empty string → 0');
|
|
136
|
+
assert.equal(countLyricLines('line one\nline two\nline three'), 3, 'three lines');
|
|
137
|
+
// LRC timestamps should be stripped before counting
|
|
138
|
+
assert.equal(
|
|
139
|
+
countLyricLines('[00:01.00] first line\n[00:02.00] second line'),
|
|
140
|
+
2,
|
|
141
|
+
'LRC lines strip timestamps'
|
|
142
|
+
);
|
|
143
|
+
divider();
|
|
144
|
+
console.log('countLyricLines: ok');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function testAutoPickPrefersContentOverEmpty() {
|
|
148
|
+
// melon-like entry: found in search but lyrics not yet fetched (empty)
|
|
149
|
+
const emptyMelon = {
|
|
150
|
+
provider: 'melon',
|
|
151
|
+
result: {
|
|
152
|
+
provider: 'melon',
|
|
153
|
+
synced: false,
|
|
154
|
+
confidence: 0.5,
|
|
155
|
+
title: 'Song',
|
|
156
|
+
artist: 'Artist',
|
|
157
|
+
plainLyrics: null,
|
|
158
|
+
syncedLyrics: null
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
// genius-like entry: has actual plain lyrics
|
|
162
|
+
const geniusWithLyrics = {
|
|
163
|
+
provider: 'genius',
|
|
164
|
+
result: {
|
|
165
|
+
provider: 'genius',
|
|
166
|
+
synced: false,
|
|
167
|
+
confidence: 0.3,
|
|
168
|
+
title: 'Song',
|
|
169
|
+
artist: 'Artist',
|
|
170
|
+
plainLyrics: 'I, I was always a mean kid\nCouldnt hold my tongue',
|
|
171
|
+
syncedLyrics: null
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Melon comes first in the list (simulating provider priority order) but is empty
|
|
176
|
+
const chooser = buildChooserEntries([emptyMelon, geniusWithLyrics]);
|
|
177
|
+
const picked = autoPick(chooser, true);
|
|
178
|
+
assert.equal(
|
|
179
|
+
picked.provider,
|
|
180
|
+
'genius',
|
|
181
|
+
'content beats empty records even when empty provider is listed first'
|
|
182
|
+
);
|
|
183
|
+
divider();
|
|
184
|
+
console.log('autoPick: content beats empty (Melon-like empty loses to Genius with lyrics): ok');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function testAutoPickRicherContentWins() {
|
|
188
|
+
const shortLyrics = {
|
|
189
|
+
provider: 'providerA',
|
|
190
|
+
result: {
|
|
191
|
+
provider: 'providerA',
|
|
192
|
+
synced: false,
|
|
193
|
+
confidence: 0.9,
|
|
194
|
+
title: 'Song',
|
|
195
|
+
artist: 'Artist',
|
|
196
|
+
plainLyrics: 'one line',
|
|
197
|
+
syncedLyrics: null
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const richLyrics = {
|
|
201
|
+
provider: 'providerB',
|
|
202
|
+
result: {
|
|
203
|
+
provider: 'providerB',
|
|
204
|
+
synced: false,
|
|
205
|
+
confidence: 0.1,
|
|
206
|
+
title: 'Song',
|
|
207
|
+
artist: 'Artist',
|
|
208
|
+
plainLyrics: Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'),
|
|
209
|
+
syncedLyrics: null
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// providerA has higher confidence but fewer lines; providerB has more content
|
|
214
|
+
const chooser = buildChooserEntries([shortLyrics, richLyrics]);
|
|
215
|
+
const picked = autoPick(chooser, false);
|
|
216
|
+
assert.equal(
|
|
217
|
+
picked.provider,
|
|
218
|
+
'providerB',
|
|
219
|
+
'richer lyric content wins even when confidence score is lower'
|
|
220
|
+
);
|
|
221
|
+
divider();
|
|
222
|
+
console.log('autoPick: richer content wins over higher-confidence sparse result: ok');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function testAutoPickSyncedWithContentBeatsSyncedEmpty() {
|
|
226
|
+
const syncedEmpty = {
|
|
227
|
+
provider: 'syncedEmpty',
|
|
228
|
+
result: {
|
|
229
|
+
provider: 'syncedEmpty',
|
|
230
|
+
synced: true,
|
|
231
|
+
confidence: 0.8,
|
|
232
|
+
title: 'Song',
|
|
233
|
+
artist: 'Artist',
|
|
234
|
+
plainLyrics: null,
|
|
235
|
+
syncedLyrics: null // synced flag set but no actual lyrics string
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const syncedWithContent = {
|
|
239
|
+
provider: 'syncedFull',
|
|
240
|
+
result: {
|
|
241
|
+
provider: 'syncedFull',
|
|
242
|
+
synced: true,
|
|
243
|
+
confidence: 0.5,
|
|
244
|
+
title: 'Song',
|
|
245
|
+
artist: 'Artist',
|
|
246
|
+
plainLyrics: null,
|
|
247
|
+
syncedLyrics: '[00:00.00] real line\n[00:02.00] another line\n[00:04.00] more'
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const chooser = buildChooserEntries([syncedEmpty, syncedWithContent]);
|
|
252
|
+
const picked = autoPick(chooser, true);
|
|
253
|
+
assert.equal(
|
|
254
|
+
picked.provider,
|
|
255
|
+
'syncedFull',
|
|
256
|
+
'synced result with actual lyrics beats synced result with no lyrics'
|
|
257
|
+
);
|
|
258
|
+
divider();
|
|
259
|
+
console.log('autoPick: synced+content beats synced+empty even with lower confidence: ok');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function testBuildPayloadFromResultReturnsCacheKey() {
|
|
263
|
+
// build a minimal find result with plain lyrics — no network call needed
|
|
264
|
+
const best = {
|
|
265
|
+
provider: 'genius',
|
|
266
|
+
title: 'Cigarette',
|
|
267
|
+
artist: 'Dylan Cotrone',
|
|
268
|
+
plainLyrics: 'I was always a mean kid\nCould not hold my tongue',
|
|
269
|
+
syncedLyrics: null,
|
|
270
|
+
synced: false
|
|
271
|
+
};
|
|
272
|
+
const result = { matches: [{ provider: 'genius', result: best }], best };
|
|
273
|
+
const context = buildActionContext({});
|
|
274
|
+
const payload = await buildPayloadFromResult(result, context);
|
|
275
|
+
|
|
276
|
+
assert.ok(payload.lyricsCacheKey, 'buildPayloadFromResult should include lyricsCacheKey when best has plain lyrics');
|
|
277
|
+
assert.equal(typeof payload.lyricsCacheKey, 'string', 'lyricsCacheKey should be a string');
|
|
278
|
+
|
|
279
|
+
// Verify the cache was actually populated
|
|
280
|
+
const cached = catalogCache.get(payload.lyricsCacheKey);
|
|
281
|
+
assert.ok(cached, 'catalog cache should have an entry for the returned key');
|
|
282
|
+
assert.ok(cached.plainLyrics, 'cached entry should include plain lyrics');
|
|
283
|
+
|
|
284
|
+
// Verify key stability — same artist/title always yields the same key
|
|
285
|
+
const expectedKey = catalogCacheKey({ artist: 'Dylan Cotrone', title: 'Cigarette' });
|
|
286
|
+
assert.equal(payload.lyricsCacheKey, expectedKey, 'lyricsCacheKey should match catalogCacheKey for the track');
|
|
287
|
+
|
|
288
|
+
divider();
|
|
289
|
+
console.log('buildPayloadFromResult returns lyricsCacheKey and populates cache: ok');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function testBuildPayloadFromResultNoCacheKeyWhenNoLyrics() {
|
|
293
|
+
const best = {
|
|
294
|
+
provider: 'melon',
|
|
295
|
+
title: 'Cigarette',
|
|
296
|
+
artist: 'Dylan Cotrone',
|
|
297
|
+
plainLyrics: null,
|
|
298
|
+
syncedLyrics: null,
|
|
299
|
+
synced: false
|
|
300
|
+
};
|
|
301
|
+
const result = { matches: [], best };
|
|
302
|
+
const context = buildActionContext({});
|
|
303
|
+
const payload = await buildPayloadFromResult(result, context);
|
|
304
|
+
|
|
305
|
+
assert.ok(!payload.lyricsCacheKey, 'buildPayloadFromResult should NOT include lyricsCacheKey when best has no lyrics');
|
|
306
|
+
|
|
307
|
+
divider();
|
|
308
|
+
console.log('buildPayloadFromResult omits lyricsCacheKey when best has no lyrics: ok');
|
|
309
|
+
}
|
|
310
|
+
|
|
91
311
|
async function run() {
|
|
92
312
|
testAutoPickPrefersSynced();
|
|
93
313
|
testAutoPickFallbackWhenNoSynced();
|
|
94
314
|
testSelectMatchRespectsProviderAndSynced();
|
|
95
315
|
testNormalizationDetectsSyncedState();
|
|
316
|
+
testLyricContentScoreEmpty();
|
|
317
|
+
testLyricContentScoreWithContent();
|
|
318
|
+
testLyricContentScoreRichness();
|
|
319
|
+
testCountLyricLines();
|
|
320
|
+
testAutoPickPrefersContentOverEmpty();
|
|
321
|
+
testAutoPickRicherContentWins();
|
|
322
|
+
testAutoPickSyncedWithContentBeatsSyncedEmpty();
|
|
323
|
+
await testBuildPayloadFromResultReturnsCacheKey();
|
|
324
|
+
await testBuildPayloadFromResultNoCacheKeyWhenNoLyrics();
|
|
96
325
|
const toolNames = mcpToolDefinitions.map((tool) => tool.name);
|
|
97
326
|
console.log('MCP tooling available:', toolNames.join(', '));
|
|
98
327
|
console.log('All sanity checks passed');
|
package/src/tools/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { exportLyrics, deriveFormatSet } from '../core/export.js';
|
|
23
23
|
import { fetchFromMelon } from '../providers/melon.js';
|
|
24
24
|
import { fetchFromGenius } from '../providers/genius.js';
|
|
25
|
+
import { lyricContentScore } from '../provider-result-schema.js';
|
|
25
26
|
|
|
26
27
|
async function hydrateSearchResult(provider, result, cache) {
|
|
27
28
|
if (result.plainLyrics || result.syncedLyrics) {
|
|
@@ -389,20 +390,56 @@ program
|
|
|
389
390
|
})
|
|
390
391
|
);
|
|
391
392
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
393
|
+
// Build rows without final indices first so we can filter/sort before numbering.
|
|
394
|
+
// Filter out any result that has no lyric content at all (empty stubs from lazy providers
|
|
395
|
+
// like Melon or Genius that were not successfully hydrated should not appear in results).
|
|
396
|
+
const unsortedRows = enriched
|
|
397
|
+
.flatMap((entry) =>
|
|
398
|
+
entry.results.map((result) => ({
|
|
399
|
+
provider: entry.provider,
|
|
400
|
+
synced: result.synced,
|
|
401
|
+
syncedRaw: result.synced,
|
|
402
|
+
title: result.title,
|
|
403
|
+
artist: result.artist,
|
|
404
|
+
plainPreview: extractPlainPreview(result),
|
|
405
|
+
syncedPreview: extractSyncedPreview(result),
|
|
406
|
+
rawResult: result
|
|
407
|
+
}))
|
|
408
|
+
)
|
|
409
|
+
.filter((row) => lyricContentScore(row.rawResult) > 0);
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Score how well a result matches the user's query (0–2).
|
|
413
|
+
* +1.0 for title match (substring, case-insensitive)
|
|
414
|
+
* +1.0 for artist match (substring, case-insensitive)
|
|
415
|
+
* This is the strongest factor so that relevant results always lead,
|
|
416
|
+
* even if an irrelevant result from the same search happens to have more lyric text.
|
|
417
|
+
*/
|
|
418
|
+
function queryRelevance(row) {
|
|
419
|
+
const qTitle = (track.title || '').toLowerCase();
|
|
420
|
+
const qArtist = (track.artist || '').toLowerCase();
|
|
421
|
+
const rTitle = (row.title || '').toLowerCase();
|
|
422
|
+
const rArtist = (row.artist || '').toLowerCase();
|
|
423
|
+
let score = 0;
|
|
424
|
+
if (qTitle && (rTitle.includes(qTitle) || qTitle.includes(rTitle))) score += 1;
|
|
425
|
+
if (qArtist && (rArtist.includes(qArtist) || qArtist.includes(rArtist))) score += 1;
|
|
426
|
+
return score;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Sort priority:
|
|
430
|
+
// 1. Relevance to query (weight ×100) — ensures on-target results always lead
|
|
431
|
+
// 2. Has actual lyric content (weight ×10) — content beats empty stubs
|
|
432
|
+
// 3. Synced bonus (0.5) + content richness — finer tie-breaking
|
|
433
|
+
// Stable JS sort preserves original provider order within equal-scoring rows.
|
|
434
|
+
unsortedRows.sort((a, b) => {
|
|
435
|
+
const relevanceA = queryRelevance(a) * 100;
|
|
436
|
+
const relevanceB = queryRelevance(b) * 100;
|
|
437
|
+
const contentA = lyricContentScore(a.rawResult) * 10 + (a.syncedRaw ? 0.5 : 0);
|
|
438
|
+
const contentB = lyricContentScore(b.rawResult) * 10 + (b.syncedRaw ? 0.5 : 0);
|
|
439
|
+
return relevanceB + contentB - (relevanceA + contentA);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const table = unsortedRows.map((row, idx) => ({ index: idx + 1, ...row }));
|
|
406
443
|
|
|
407
444
|
if (table.length === 0) {
|
|
408
445
|
console.log('No matches found');
|
|
@@ -594,7 +631,13 @@ program
|
|
|
594
631
|
providerNames,
|
|
595
632
|
syncedOnly: options.syncedOnly
|
|
596
633
|
});
|
|
597
|
-
|
|
634
|
+
// Filter out matches with no lyric content before building the chooser so that
|
|
635
|
+
// empty provider stubs (e.g. Melon returning a shell record with no lyrics) don't
|
|
636
|
+
// appear as selectable options.
|
|
637
|
+
const contentMatches = (Array.isArray(result.matches) ? result.matches : []).filter(
|
|
638
|
+
(entry) => lyricContentScore(entry.result) > 0
|
|
639
|
+
);
|
|
640
|
+
chooserEntries = buildChooserEntries(contentMatches);
|
|
598
641
|
totalResults = chooserEntries.length;
|
|
599
642
|
|
|
600
643
|
if ((options.choose || options.index) && totalResults === 0) {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
|
|
3
3
|
import { runFind, runSearch, runProviderSearch } from '../core/find-service.js';
|
|
4
|
+
import { lyricContentScore } from '../provider-result-schema.js';
|
|
4
5
|
import {
|
|
5
6
|
buildActionContext,
|
|
6
7
|
buildPayloadFromResult,
|
|
7
8
|
buildCatalogPayload,
|
|
8
9
|
exportBestResult,
|
|
9
10
|
formatRecord,
|
|
10
|
-
catalogCache
|
|
11
|
+
catalogCache,
|
|
12
|
+
catalogCacheKey
|
|
11
13
|
} from '../services/lyrics-service.js';
|
|
12
14
|
import { pushCatalogToAirtable } from '../services/airtable-writer.js';
|
|
13
15
|
import { getProviderStatus } from '../index.js';
|
|
@@ -170,7 +172,10 @@ const matchSchema = {
|
|
|
170
172
|
export const mcpToolDefinitions = [
|
|
171
173
|
{
|
|
172
174
|
name: 'find_lyrics',
|
|
173
|
-
description:
|
|
175
|
+
description:
|
|
176
|
+
'Find the best lyric match across providers (prefers synced when available). ' +
|
|
177
|
+
'Returns lyricsCacheKey when lyrics are resolved — pass this to push_catalog_to_airtable ' +
|
|
178
|
+
'without calling build_catalog_payload first.',
|
|
174
179
|
inputSchema: {
|
|
175
180
|
type: 'object',
|
|
176
181
|
description: 'Provide a track description (and optional hints) to look up lyrics.',
|
|
@@ -201,7 +206,9 @@ export const mcpToolDefinitions = [
|
|
|
201
206
|
},
|
|
202
207
|
{
|
|
203
208
|
name: 'find_synced_lyrics',
|
|
204
|
-
description:
|
|
209
|
+
description:
|
|
210
|
+
'Find lyrics but reject any candidates that lack timestamps. ' +
|
|
211
|
+
'Returns lyricsCacheKey when synced lyrics are resolved.',
|
|
205
212
|
inputSchema: {
|
|
206
213
|
type: 'object',
|
|
207
214
|
description:
|
|
@@ -252,7 +259,9 @@ export const mcpToolDefinitions = [
|
|
|
252
259
|
},
|
|
253
260
|
{
|
|
254
261
|
name: 'export_lyrics',
|
|
255
|
-
description:
|
|
262
|
+
description:
|
|
263
|
+
'Find lyrics and save plain/LRC/SRT plus romanized variants to disk. ' +
|
|
264
|
+
'Also returns lyricsCacheKey so push_catalog_to_airtable can be called immediately.',
|
|
256
265
|
inputSchema: {
|
|
257
266
|
type: 'object',
|
|
258
267
|
description: 'Provide the track and export options to write files to disk.',
|
|
@@ -265,7 +274,9 @@ export const mcpToolDefinitions = [
|
|
|
265
274
|
},
|
|
266
275
|
{
|
|
267
276
|
name: 'format_lyrics',
|
|
268
|
-
description:
|
|
277
|
+
description:
|
|
278
|
+
'Find lyrics and return formatted text (with optional romanization) in-memory. ' +
|
|
279
|
+
'Also returns lyricsCacheKey so push_catalog_to_airtable can be called immediately.',
|
|
269
280
|
inputSchema: {
|
|
270
281
|
type: 'object',
|
|
271
282
|
description: 'Provide the track and formatting options for in-memory rendering.',
|
|
@@ -406,7 +417,19 @@ export async function handleMcpTool(name, args = {}) {
|
|
|
406
417
|
includeRomanization: !options?.noRomanize,
|
|
407
418
|
includeSynced: options?.includeSynced ?? true
|
|
408
419
|
});
|
|
409
|
-
|
|
420
|
+
// Populate the catalog cache so push_catalog_to_airtable can be used
|
|
421
|
+
// immediately without a separate build_catalog_payload call.
|
|
422
|
+
let lyricsCacheKey = null;
|
|
423
|
+
const plainLyrics = formatted.plainLyrics || best.plainLyrics || '';
|
|
424
|
+
if (plainLyrics) {
|
|
425
|
+
lyricsCacheKey = catalogCacheKey({ artist: best.artist, title: best.title });
|
|
426
|
+
catalogCache.set(lyricsCacheKey, {
|
|
427
|
+
plainLyrics,
|
|
428
|
+
romanizedPlainLyrics: formatted.romanizedPlain || null,
|
|
429
|
+
preferRomanized: !options?.noRomanize
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return { formatted, best, lyricsCacheKey };
|
|
410
433
|
}
|
|
411
434
|
|
|
412
435
|
if (name === 'build_catalog_payload') {
|
|
@@ -422,7 +445,12 @@ export async function handleMcpTool(name, args = {}) {
|
|
|
422
445
|
return { error: 'No matches provided' };
|
|
423
446
|
}
|
|
424
447
|
const { provider, requireSynced, index } = args.criteria || {};
|
|
425
|
-
|
|
448
|
+
// Strip entries with no actual lyric content (e.g. unhydrated search stubs)
|
|
449
|
+
// so the caller never accidentally selects an empty result.
|
|
450
|
+
let filtered = matches.filter((entry) => lyricContentScore(entry.result) > 0);
|
|
451
|
+
if (filtered.length === 0) {
|
|
452
|
+
return { error: 'No matches with lyric content found' };
|
|
453
|
+
}
|
|
426
454
|
if (provider) {
|
|
427
455
|
filtered = filtered.filter((entry) => entry.provider === provider);
|
|
428
456
|
}
|