@kolbo/mcp 1.31.3 → 1.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.31.3",
3
+ "version": "1.31.5",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -263,16 +263,43 @@ function renderScenes(sc) {
263
263
  var media = (scene.video_urls || []).map(function (u) {
264
264
  return '<div class="k-media"><video src="' + esc(u) + '" controls playsinline></video>' + dlBtnHTML(u) + '</div>';
265
265
  }).join('') + (scene.image_urls || []).map(function (u) {
266
- return '<div class="k-media"><img src="' + esc(u) + '" alt="">' + dlBtnHTML(u) + '</div>';
266
+ return '<div class="k-media" data-focus="' + esc(u) + '"><img src="' + esc(u) + '" alt="">' + dlBtnHTML(u) + '</div>';
267
267
  }).join('');
268
268
  return '<div style="margin-bottom:14px"><div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:8px">Scene ' +
269
269
  esc(scene.scene_number) + (scene.title ? ' — ' + esc(scene.title) : '') + '</div>' +
270
270
  '<div class="k-gen-grid n2">' + media + '</div></div>';
271
271
  }).join('');
272
272
  wireDlButtons(el('stage'));
273
+ // Click a scene image → fullscreen focus on THAT image (videos keep their
274
+ // native controls; clicking them shouldn't hijack playback).
275
+ Array.prototype.forEach.call(el('stage').querySelectorAll('.k-media[data-focus]'), function (m) {
276
+ m.style.cursor = 'zoom-in';
277
+ m.onclick = function () { focusMedia(m.getAttribute('data-focus')); };
278
+ });
273
279
  renderActions(sc);
274
280
  }
275
281
 
282
+ // Fullscreen a single item out of a multi-item grid (Creative Director
283
+ // scenes). Exit restores the grid.
284
+ function focusMedia(url) {
285
+ window.kolbo.requestDisplayMode('fullscreen').then(function (res) {
286
+ if (!(res && res.mode === 'fullscreen')) return window.kolbo.openLink(url);
287
+ isFullscreen = true;
288
+ el('stage').innerHTML = '<div class="k-viewer"><img id="focus-img" src="' + esc(url) + '" alt="" style="cursor:zoom-out">' + dlBtnHTML(url) + '</div>';
289
+ wireDlButtons(el('stage'));
290
+ el('focus-img').onclick = exitFocus;
291
+ applyFullscreen(true, exitFocus);
292
+ window.kolbo.notifySize();
293
+ }).catch(function () { window.kolbo.openLink(url); });
294
+ }
295
+ function exitFocus() {
296
+ window.kolbo.requestDisplayMode('inline').catch(function () {});
297
+ isFullscreen = false;
298
+ applyFullscreen(false);
299
+ renderScenes(state); // restore the grid
300
+ window.kolbo.notifySize();
301
+ }
302
+
276
303
  function renderError(msg) {
277
304
  clearTimeout(pollTimer);
278
305
 
@@ -302,14 +329,14 @@ function toggleFullscreen() {
302
329
  if (!isFullscreen) window.kolbo.openLink(state.urls && state.urls[selected]);
303
330
  });
304
331
  }
305
- function applyFullscreen(on) {
332
+ function applyFullscreen(on, exitHandler) {
306
333
  document.documentElement.classList.toggle('k-fullscreen', on);
307
334
  var c = el('phase-chip');
308
335
  if (on) {
309
336
  c.style.display = '';
310
337
  c.innerHTML = '✕ ' + esc('Exit');
311
338
  c.style.cursor = 'pointer';
312
- c.onclick = toggleFullscreen;
339
+ c.onclick = exitHandler || toggleFullscreen;
313
340
  } else {
314
341
  c.style.display = 'none';
315
342
  c.onclick = null;
@@ -1,85 +1,117 @@
1
1
  /* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
2
- * CONTRACT. Never rename, remove, or break an existing tool/arg. Full rules: ../index.js top-of-file. */
2
+ * CONTRACT. Never rename, remove, or break an existing tool/arg. Full rules: ../index.js top-of-file.
3
+ *
4
+ * DEPRECATED FAMILY (2026-07-11): these tools originally fronted the dedicated
5
+ * Synci-only /v1/music-library/* routes. The Synci partner key expired and the
6
+ * unified STOCK LIBRARY covers music anyway (kolbo-ai catalog + Coverr + Synci
7
+ * when its key is live), so every tool here is now a thin adapter over
8
+ * /v1/stock/* — same tool names/args, better backend, and old cached installs
9
+ * keep working. New integrations should use search_stock_media /
10
+ * get_stock_asset with mediaType "music" directly.
11
+ */
3
12
 
4
13
  const { z } = require('zod');
5
14
  const { UI, uiResult, appsEnabled } = require('../apps');
6
15
 
7
- // Format a normalized track into a compact human-readable line.
8
- function trackLine(t) {
16
+ const DEPRECATION_NOTE = '[Deprecated served by the unified Stock Library now; prefer search_stock_media / get_stock_asset with mediaType "music".] ';
17
+
18
+ // Format a stock music asset into a compact human-readable line.
19
+ function assetTrackLine(a) {
9
20
  const meta = [
10
- t.durationSeconds != null ? `${Math.round(t.durationSeconds)}s` : null,
11
- t.bpm != null ? `${t.bpm} BPM` : null,
12
- t.musicalKey || null,
13
- Array.isArray(t.genres) && t.genres.length ? t.genres.join('/') : t.genre,
14
- Array.isArray(t.moodTags) && t.moodTags.length ? t.moodTags.slice(0, 3).join(', ') : null,
21
+ a.durationSeconds != null ? `${Math.round(a.durationSeconds)}s` : null,
22
+ a.author?.name || null,
23
+ a.source,
15
24
  ].filter(Boolean).join(' · ');
16
- const flags = [t.hasStems ? 'stems' : null, t.hasLyrics ? 'lyrics' : null].filter(Boolean).join(', ');
17
- return `${t.id} — ${t.title}${t.artist ? ` by ${t.artist}` : ''}\n ${meta}${flags ? ` [${flags}]` : ''}${t.audioUrl ? `\n preview: ${t.audioUrl}` : ''}`;
25
+ const preview = a.previewUrl || a.downloadVariants?.[0]?.url || null;
26
+ return `${a.source}:${a.sourceId} ${a.title || '(untitled)'}\n ${meta}${preview ? `\n preview: ${preview}` : ''}`;
18
27
  }
19
28
 
20
- // Map a normalized track onto the media-grid widget item contract.
21
- function trackItem(t) {
29
+ // Map a stock music asset onto the media-grid widget item contract.
30
+ function assetTrackItem(a) {
22
31
  return {
23
- id: t.id,
24
- title: t.title,
32
+ id: `${a.source}:${a.sourceId}`,
33
+ title: a.title || '(untitled)',
25
34
  subtitle: [
26
- t.artist,
27
- t.durationSeconds != null ? Math.round(t.durationSeconds) + 's' : null,
28
- Array.isArray(t.genres) && t.genres.length ? t.genres.slice(0, 2).join('/') : (t.genre || null)
35
+ a.author?.name || null,
36
+ a.durationSeconds != null ? Math.round(a.durationSeconds) + 's' : null,
37
+ a.source,
29
38
  ].filter(Boolean).join(' · '),
30
- thumbnail: t.artworkUrl || null,
39
+ thumbnail: a.thumbnailUrl || null,
31
40
  media_type: 'audio',
32
- preview_audio: t.audioUrl || t.audioUrl128 || null,
41
+ preview_audio: a.previewUrl || a.downloadVariants?.[0]?.url || null,
33
42
  use_hint: 'Get download links for track "{TITLE}" (id: {ID}) via get_music_track_audio.'
34
43
  };
35
44
  }
36
45
 
46
+ // Search the unified stock library for music. `query` may be empty (browse).
47
+ async function stockMusicSearch(client, query, limit, offset) {
48
+ const params = new URLSearchParams();
49
+ if (query) params.set('query', query);
50
+ params.set('mediaType', 'music');
51
+ params.set('source', 'all');
52
+ params.set('perPage', String(Math.min(Math.max(limit || 20, 1), 40)));
53
+ if (offset) params.set('page', String(Math.floor(offset / (limit || 20)) + 1));
54
+ const result = await client.get(`/v1/stock/search?${params.toString()}`);
55
+ return { assets: result.assets || [], total: result.total };
56
+ }
57
+
58
+ // "source:sourceId" (new) or a bare legacy id (assume synci — the old backend).
59
+ function parseTrackId(trackId) {
60
+ const i = String(trackId).indexOf(':');
61
+ if (i > 0) return { source: trackId.slice(0, i), id: trackId.slice(i + 1) };
62
+ return { source: 'synci', id: trackId };
63
+ }
64
+
65
+ function musicResult(ui, args, assets, total, emptyText) {
66
+ if (assets.length === 0) {
67
+ return { content: [{ type: 'text', text: emptyText }] };
68
+ }
69
+ const head = `Found ${assets.length} track${assets.length === 1 ? '' : 's'}${total ? ` (of ${total})` : ''}:`;
70
+ const text = `${head}\n\n${assets.map(assetTrackLine).join('\n\n')}\n\nUse the track id with get_music_track_audio to get downloadable URLs (or get_stock_asset directly).`;
71
+ if (ui()) {
72
+ return uiResult(UI.mediaGrid, text, {
73
+ widget: 'media-grid',
74
+ title: 'Music Library' + (args && args.query ? ' — "' + args.query + '"' : ''),
75
+ items: assets.slice(0, 20).map(assetTrackItem),
76
+ total: total != null ? total : assets.length
77
+ });
78
+ }
79
+ return { content: [{ type: 'text', text }] };
80
+ }
81
+
37
82
  function registerMusicLibraryTools(server, client, options = {}) {
38
83
  const ui = () => appsEnabled(server, options);
39
- // ─── search_music_library ─────────────────────────────────────
84
+
85
+ // ─── search_music_library (adapter → stock search) ─────────────
40
86
  server.tool(
41
87
  'search_music_library',
42
- 'Search the Kolbo stock / production music library (licensed background tracks) by keyword with optional filters. Use this to FIND an existing ready-made track to score a video, ad, or voiceover — distinct from generate_music, which composes a brand-new song with Suno. Returns matching tracks with id, title, artist, duration, BPM, key, genres, moods, and preview/download URLs (128/320/wav). To turn a script into a good query first, call analyze_script_for_music.',
88
+ DEPRECATION_NOTE + 'Search stock/production music by keyword. Mood/genre are folded into the semantic query (the Kolbo catalog matches by vibe, e.g. "uplifting corporate", "tense cinematic"). Returns tracks with id, title, duration, and preview/download URLs. For scripts, call analyze_script_for_music first.',
43
89
  {
44
- query: z.string().max(200).optional().describe('Keyword search, e.g. "uplifting corporate", "tense cinematic", "lofi hip hop". If omitted, falls back to the mood/genre filter as the search term.'),
45
- mood: z.string().optional().describe('Mood filter, e.g. "Emotional", "Energetic", "Tense". Use get_music_library_facets to see valid values.'),
46
- genre: z.string().optional().describe('Genre filter, e.g. "Soundtrack", "Corporate", "Hip Hop". Use get_music_library_facets to see valid values.'),
47
- bpmMin: z.number().optional().describe('Minimum beats-per-minute.'),
48
- bpmMax: z.number().optional().describe('Maximum beats-per-minute.'),
49
- durationMin: z.number().optional().describe('Minimum track duration in seconds.'),
50
- durationMax: z.number().optional().describe('Maximum track duration in seconds.'),
51
- hasStems: z.boolean().optional().describe('Only return tracks that have separated stems.'),
52
- hasLyrics: z.boolean().optional().describe('Only return tracks that have lyrics.'),
53
- sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Optional sort order. Omit for relevance order.'),
90
+ query: z.string().max(200).optional().describe('Keyword/vibe search, e.g. "uplifting corporate", "tense cinematic", "lofi hip hop". If omitted, falls back to the mood/genre filter as the search term.'),
91
+ mood: z.string().optional().describe('Mood keyword, folded into the search query (e.g. "Emotional", "Energetic").'),
92
+ genre: z.string().optional().describe('Genre keyword, folded into the search query (e.g. "Corporate", "Hip Hop").'),
93
+ bpmMin: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
94
+ bpmMax: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
95
+ durationMin: z.number().optional().describe('Legacy filter accepted but no longer applied.'),
96
+ durationMax: z.number().optional().describe('Legacy filter accepted but no longer applied.'),
97
+ hasStems: z.boolean().optional().describe('Legacy filter accepted but no longer applied.'),
98
+ hasLyrics: z.boolean().optional().describe('Legacy filter accepted but no longer applied.'),
99
+ sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Legacy sort accepted but no longer applied (relevance order).'),
54
100
  limit: z.number().int().min(1).max(40).optional().describe('Results per page (max 40, default 20).'),
55
101
  offset: z.number().int().min(0).optional().describe('Pagination offset for loading more results.')
56
102
  },
57
103
  async (args) => {
58
- const result = await client.post('/v1/music-library/search', args);
59
- const tracks = result.tracks || [];
60
- if (tracks.length === 0) {
61
- return { content: [{ type: 'text', text: 'No tracks found matching those filters. Try a broader query or call get_music_library_facets for valid genres/moods.' }] };
62
- }
63
- const head = `Found ${tracks.length} track${tracks.length === 1 ? '' : 's'}${result.total ? ` (of ${result.total} sorted)` : ''}:`;
64
- const text = `${head}\n\n${tracks.map(trackLine).join('\n\n')}\n\nUse the track id with get_music_track_audio to get the downloadable 128/320/wav URLs.`;
65
-
66
- if (ui()) {
67
- return uiResult(UI.mediaGrid, text, {
68
- widget: 'media-grid',
69
- title: 'Music Library' + (args.query ? ' — "' + args.query + '"' : ''),
70
- items: tracks.slice(0, 20).map(trackItem),
71
- total: result.total != null ? result.total : tracks.length
72
- });
73
- }
74
-
75
- return { content: [{ type: 'text', text }] };
104
+ const query = [args.query, args.mood, args.genre].filter(Boolean).join(' ').trim();
105
+ const { assets, total } = await stockMusicSearch(client, query, args.limit, args.offset);
106
+ return musicResult(ui, args, assets, total,
107
+ 'No tracks found matching that query. Try a broader vibe description, or use search_stock_media with mediaType="music".');
76
108
  }
77
109
  );
78
110
 
79
- // ─── analyze_script_for_music ─────────────────────────────────
111
+ // ─── analyze_script_for_music (unchanged — doesn't touch Synci) ─
80
112
  server.tool(
81
113
  'analyze_script_for_music',
82
- 'AI helper that turns a video or voiceover script into a music search. Returns { query, mood, genre, keywords } you can pass straight into search_music_library to find a fitting background track. Use this first when the user gives you a script/scene description rather than explicit music keywords.',
114
+ 'AI helper that turns a video or voiceover script into a music search. Returns { query, mood, genre, keywords } you can pass straight into search_music_library (or search_stock_media with mediaType="music") to find a fitting background track. Use this first when the user gives you a script/scene description rather than explicit music keywords.',
83
115
  {
84
116
  script: z.string().min(1).describe('The video or voiceover script / scene description to analyze (up to ~8000 chars).')
85
117
  },
@@ -93,127 +125,112 @@ function registerMusicLibraryTools(server, client, options = {}) {
93
125
  mood: result.mood,
94
126
  genre: result.genre,
95
127
  keywords: result.keywords,
96
- _followup_hint: 'Pass query + mood + genre into search_music_library.'
128
+ _followup_hint: 'Pass query + mood + genre into search_music_library (or search_stock_media mediaType="music").'
97
129
  }, null, 2)
98
130
  }]
99
131
  };
100
132
  }
101
133
  );
102
134
 
103
- // ─── browse_music_library ─────────────────────────────────────
135
+ // ─── browse_music_library (adapter → stock browse feed) ────────
104
136
  server.tool(
105
137
  'browse_music_library',
106
- 'Browse the music library catalog without a search query (stable paginated listing). Use when the user just wants to see what is available. For a targeted search use search_music_library instead.',
138
+ DEPRECATION_NOTE + 'Browse the music catalog without a search query (paginated feed). For targeted search use search_music_library or search_stock_media.',
107
139
  {
108
- sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Optional sort order.'),
109
- limit: z.number().int().min(1).max(50).optional().describe('Results per page (max 50, default 50).'),
140
+ sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Legacy sort — accepted but no longer applied (feed order).'),
141
+ limit: z.number().int().min(1).max(50).optional().describe('Results per page (max 40 now, default 20).'),
110
142
  offset: z.number().int().min(0).optional().describe('Pagination offset for loading more results.')
111
143
  },
112
- async ({ sort, limit, offset }) => {
113
- const params = new URLSearchParams();
114
- if (sort) params.set('sort', sort);
115
- if (limit != null) params.set('limit', String(limit));
116
- if (offset != null) params.set('offset', String(offset));
117
- const path = `/v1/music-library/catalog${params.toString() ? '?' + params.toString() : ''}`;
118
- const result = await client.get(path);
119
- const tracks = result.tracks || [];
120
- if (tracks.length === 0) {
121
- return { content: [{ type: 'text', text: 'No tracks returned.' }] };
122
- }
123
- const text = `Catalog (${tracks.length} track${tracks.length === 1 ? '' : 's'}):\n\n${tracks.map(trackLine).join('\n\n')}`;
124
-
125
- if (ui()) {
126
- return uiResult(UI.mediaGrid, text, {
127
- widget: 'media-grid',
128
- title: 'Music Library',
129
- items: tracks.slice(0, 20).map(trackItem),
130
- total: result.total != null ? result.total : tracks.length
131
- });
132
- }
133
-
134
- return { content: [{ type: 'text', text }] };
144
+ async ({ limit, offset }) => {
145
+ const { assets, total } = await stockMusicSearch(client, '', limit, offset);
146
+ return musicResult(ui, null, assets, total, 'No tracks returned.');
135
147
  }
136
148
  );
137
149
 
138
- // ─── get_music_library_facets ─────────────────────────────────
150
+ // ─── get_music_library_facets (adapter → stock categories) ─────
139
151
  server.tool(
140
152
  'get_music_library_facets',
141
- 'List the distinct genres, moods, and instruments available in the music library, plus the BPM and duration ranges. Use these values to build precise search_music_library filters.',
153
+ DEPRECATION_NOTE + 'List the music category/genre chips available in the stock library. The catalog matches semantically, so any natural-language vibe also works as a query.',
142
154
  {},
143
155
  async () => {
144
- const result = await client.get('/v1/music-library/facets');
156
+ let categories = [];
157
+ try {
158
+ const result = await client.get('/v1/stock/categories?mediaType=music');
159
+ categories = (result.categories || []).map(c => c.name || c.providerParam).filter(Boolean);
160
+ } catch (_) { /* categories are best-effort */ }
145
161
  return {
146
162
  content: [{
147
163
  type: 'text',
148
164
  text: JSON.stringify({
149
- genres: result.genres || [],
150
- moods: result.moods || [],
151
- instruments: result.instruments || [],
152
- bpmRange: result.bpmRange || null,
153
- durationRange: result.durationRange || null
165
+ genres: categories,
166
+ moods: [],
167
+ instruments: [],
168
+ bpmRange: null,
169
+ durationRange: null,
170
+ _note: 'Semantic search: any natural-language mood/vibe works as a query — exact facet values are no longer required.'
154
171
  }, null, 2)
155
172
  }]
156
173
  };
157
174
  }
158
175
  );
159
176
 
160
- // ─── get_music_track_audio ────────────────────────────────────
177
+ // ─── get_music_track_audio (adapter → stock asset) ──────────────
161
178
  server.tool(
162
179
  'get_music_track_audio',
163
- 'Get the downloadable audio URLs (128 kbps / 320 kbps / WAV) for a single music-library track by id. Call this after the user picks a track from search_music_library or browse_music_library.',
180
+ DEPRECATION_NOTE + 'Get the downloadable audio URLs for a music track by id ("source:sourceId" from search results).',
164
181
  {
165
- track_id: z.string().describe('The track id returned by search_music_library / browse_music_library.')
182
+ track_id: z.string().describe('Track id from search_music_library / browse_music_library (format "source:sourceId").')
166
183
  },
167
184
  async ({ track_id }) => {
168
- const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/audio`);
185
+ const { source, id } = parseTrackId(track_id);
186
+ // mediaType hint required for sources that share ids across types (kolbo-ai).
187
+ const result = await client.get(`/v1/stock/asset/${encodeURIComponent(source)}/${encodeURIComponent(id)}?mediaType=music`);
188
+ const a = result.asset || result;
189
+ const urls = {};
190
+ (a.downloadVariants || []).forEach(v => { if (v.url) urls[v.label || 'audio'] = v.url; });
169
191
  return {
170
192
  content: [{
171
193
  type: 'text',
172
- text: JSON.stringify({ id: result.id, urls: result.urls }, null, 2)
194
+ text: JSON.stringify({ id: track_id, title: a.title, urls }, null, 2)
173
195
  }]
174
196
  };
175
197
  }
176
198
  );
177
199
 
178
- // ─── get_music_track_related ──────────────────────────────────
200
+ // ─── get_music_track_related (graceful stub — no stock equivalent) ─
179
201
  server.tool(
180
202
  'get_music_track_related',
181
- 'Get the stems and alternate versions of a music-library master track by id (e.g. instrumental, 30s cut, looped).',
203
+ DEPRECATION_NOTE + 'Stems/alternate versions are not exposed by the unified stock library. Returns an empty set with guidance.',
182
204
  {
183
205
  track_id: z.string().describe('The master track id.')
184
206
  },
185
- async ({ track_id }) => {
186
- const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/related`);
187
- return {
188
- content: [{
189
- type: 'text',
190
- text: JSON.stringify({ stems: result.stems || [], versions: result.versions || [] }, null, 2)
191
- }]
192
- };
193
- }
207
+ async ({ track_id }) => ({
208
+ content: [{
209
+ type: 'text',
210
+ text: JSON.stringify({
211
+ stems: [], versions: [],
212
+ _note: `Stems/alternate versions are not available via the stock library. Use get_music_track_audio ("${track_id}") for the downloadable variants, or generate_music to compose a custom track.`
213
+ }, null, 2)
214
+ }]
215
+ })
194
216
  );
195
217
 
196
- // ─── get_music_track_lyrics ───────────────────────────────────
218
+ // ─── get_music_track_lyrics (graceful stub — no stock equivalent) ─
197
219
  server.tool(
198
220
  'get_music_track_lyrics',
199
- 'Get the lyrics text, lyrical theme, and explicit flag for a single music-library track by id.',
221
+ DEPRECATION_NOTE + 'Lyrics metadata is not exposed by the unified stock library. Returns hasLyrics: false with guidance.',
200
222
  {
201
223
  track_id: z.string().describe('The track id.')
202
224
  },
203
- async ({ track_id }) => {
204
- const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/lyrics`);
205
- return {
206
- content: [{
207
- type: 'text',
208
- text: JSON.stringify({
209
- hasLyrics: result.hasLyrics,
210
- lyrics: result.lyrics,
211
- lyricalTheme: result.lyricalTheme,
212
- explicit: result.explicit
213
- }, null, 2)
214
- }]
215
- };
216
- }
225
+ async () => ({
226
+ content: [{
227
+ type: 'text',
228
+ text: JSON.stringify({
229
+ hasLyrics: false, lyrics: null, lyricalTheme: null, explicit: null,
230
+ _note: 'Lyrics metadata is not available via the stock library. For a song with specific lyrics, use generate_music with the lyrics field.'
231
+ }, null, 2)
232
+ }]
233
+ })
217
234
  );
218
235
  }
219
236