mr-magic-mcp-server 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env.example +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1118 -0
  4. package/package.json +75 -0
  5. package/prompts/airtable-song-importer.md +239 -0
  6. package/src/bin/cli.js +9 -0
  7. package/src/bin/http-server.js +7 -0
  8. package/src/bin/mcp-http-server.js +7 -0
  9. package/src/bin/mcp-server.js +6 -0
  10. package/src/core/export.js +83 -0
  11. package/src/core/find-service.js +66 -0
  12. package/src/core/formatting.js +39 -0
  13. package/src/core/preview.js +54 -0
  14. package/src/index.js +138 -0
  15. package/src/provider-result-schema.js +63 -0
  16. package/src/providers/genius.js +250 -0
  17. package/src/providers/lrclib.js +73 -0
  18. package/src/providers/melon.js +201 -0
  19. package/src/providers/musixmatch.js +165 -0
  20. package/src/services/airtable-writer.js +246 -0
  21. package/src/services/lyrics-service.js +372 -0
  22. package/src/tools/cli.js +695 -0
  23. package/src/transport/http-server.js +151 -0
  24. package/src/transport/mcp-http-server.js +202 -0
  25. package/src/transport/mcp-response.js +44 -0
  26. package/src/transport/mcp-server.js +77 -0
  27. package/src/transport/mcp-tools.js +513 -0
  28. package/src/transport/token-startup-log.js +133 -0
  29. package/src/transport/tool-args.js +113 -0
  30. package/src/utils/config.js +57 -0
  31. package/src/utils/export-storage/inline-storage.js +7 -0
  32. package/src/utils/export-storage/local-storage.js +39 -0
  33. package/src/utils/export-storage/redis-storage.js +36 -0
  34. package/src/utils/export-storage/shared-redis-client.js +69 -0
  35. package/src/utils/export-storage.js +29 -0
  36. package/src/utils/logger.js +74 -0
  37. package/src/utils/lyrics-format.js +170 -0
  38. package/src/utils/slugify.js +18 -0
  39. package/src/utils/storage-cache.js +29 -0
  40. package/src/utils/tokens/genius-token-manager.js +119 -0
  41. package/src/utils/tokens/musixmatch-token-manager.js +130 -0
@@ -0,0 +1,201 @@
1
+ import axios from 'axios';
2
+ import * as cheerio from 'cheerio';
3
+
4
+ import { normalizeLyricRecord, recomputeSyncFlags } from '../provider-result-schema.js';
5
+ import { MELON_COOKIE, warnMissingEnv } from '../utils/config.js';
6
+ import { createLogger } from '../utils/logger.js';
7
+
8
+ const SEARCH_URL = 'https://www.melon.com/search/song/index.htm';
9
+ const LYRIC_URL = 'https://www.melon.com/song/lyricInfo.json';
10
+ const HTTP_TIMEOUT_MS = Number(process.env.MR_MAGIC_HTTP_TIMEOUT_MS || 10000);
11
+ const USER_AGENT =
12
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36';
13
+ let cachedMelonCookie = null;
14
+ const logger = createLogger('provider:melon');
15
+
16
+ async function ensureMelonCookie() {
17
+ const manualCookie = typeof MELON_COOKIE === 'function' ? MELON_COOKIE() : MELON_COOKIE;
18
+ if (manualCookie) {
19
+ cachedMelonCookie = manualCookie;
20
+ return manualCookie;
21
+ }
22
+ if (cachedMelonCookie) return cachedMelonCookie;
23
+ const response = await axios.get('https://www.melon.com', {
24
+ timeout: HTTP_TIMEOUT_MS,
25
+ headers: {
26
+ 'User-Agent': USER_AGENT,
27
+ Accept: 'text/html'
28
+ }
29
+ });
30
+ const setCookies = response.headers['set-cookie'] || [];
31
+ const cookie = setCookies.map((entry) => entry.split(';')[0]).join('; ');
32
+ cachedMelonCookie = cookie;
33
+ return cookie;
34
+ }
35
+
36
+ async function buildSearchHeaders() {
37
+ const headers = {
38
+ 'User-Agent': USER_AGENT,
39
+ Accept:
40
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
41
+ 'Accept-Language': 'en-US,en;q=0.9',
42
+ 'sec-ch-ua': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
43
+ 'sec-ch-ua-mobile': '?0',
44
+ 'sec-ch-ua-platform': '"macOS"',
45
+ Referer: 'https://www.melon.com/search/song/index.htm',
46
+ 'Sec-Fetch-Dest': 'document',
47
+ 'Sec-Fetch-Mode': 'navigate',
48
+ 'Sec-Fetch-Site': 'same-origin',
49
+ 'Sec-Fetch-User': '?1',
50
+ 'Upgrade-Insecure-Requests': '1'
51
+ };
52
+ if (!cachedMelonCookie) {
53
+ warnMissingEnv(['MELON_COOKIE']);
54
+ }
55
+ const cookie = await ensureMelonCookie();
56
+ if (cookie) headers.Cookie = cookie;
57
+ return headers;
58
+ }
59
+
60
+ async function fetchSearchPage(track) {
61
+ const headers = await buildSearchHeaders();
62
+ const response = await axios.get(SEARCH_URL, {
63
+ timeout: HTTP_TIMEOUT_MS,
64
+ headers,
65
+ params: {
66
+ q: `${(track.artist || '').trim()} ${(track.title || '').trim()}`.trim(),
67
+ section: '',
68
+ mwkLogType: 'T',
69
+ searchGnbYn: 'Y',
70
+ kkoSpl: 'N',
71
+ kkoDpType: '',
72
+ searchType: 1,
73
+ searchGubun: 1
74
+ }
75
+ });
76
+ return response.data;
77
+ }
78
+
79
+ async function buildLyricHeaders(songId) {
80
+ const headers = {
81
+ 'User-Agent': USER_AGENT,
82
+ Accept: 'application/json, text/javascript, */*; q=0.01',
83
+ Referer: `https://www.melon.com/song/detail.htm?songId=${songId}`
84
+ };
85
+ const cookie = await ensureMelonCookie();
86
+ if (cookie) headers.Cookie = cookie;
87
+ return headers;
88
+ }
89
+
90
+ function extractSongId(value) {
91
+ if (!value) return null;
92
+ const goSongMatch = value.match(/goSongDetail\([^0-9]*?(\d+)\)/);
93
+ if (goSongMatch) return goSongMatch[1];
94
+ const searchLogMatch = value.match(/searchLog\('[^']+','[^']+','[^']+','[^']+','(\d+)'\)/);
95
+ if (searchLogMatch) return searchLogMatch[1];
96
+ const playMatch = value.match(/playSong\([^0-9]*?(\d+)\)/);
97
+ if (playMatch) return playMatch[1];
98
+ return null;
99
+ }
100
+
101
+ function parseSearchPage(html) {
102
+ const $ = cheerio.load(html);
103
+ const seenIds = new Set();
104
+ return $('#frm_defaultList > div > table > tbody > tr')
105
+ .map((_, row) => {
106
+ const $row = $(row);
107
+ const cells = $row.find('td');
108
+ const titleAnchor = cells.eq(2).find('a.fc_gray').first();
109
+ const artistAnchor = cells.eq(3).find('#artistName > a').first();
110
+ const albumAnchor = cells.eq(4).find('a').first();
111
+ const titleHref = titleAnchor.attr('href') || titleAnchor.attr('onclick') || '';
112
+ const artistHref = artistAnchor.attr('href') || artistAnchor.attr('onclick') || '';
113
+ let songId = extractSongId(titleHref) || extractSongId(artistHref);
114
+ if (!songId) songId = extractSongId($row.html() || '');
115
+ if (!songId || seenIds.has(songId)) {
116
+ return null;
117
+ }
118
+ const title = titleAnchor.text().trim().replace(/\s+/g, ' ');
119
+ const artist = artistAnchor.text().trim().replace(/\s+/g, ' ');
120
+ const album = albumAnchor.text().trim().replace(/\s+/g, ' ');
121
+ seenIds.add(songId);
122
+ if (!title && !artist) return null;
123
+ return { songId, title, artist, album };
124
+ })
125
+ .get()
126
+ .filter(Boolean);
127
+ }
128
+
129
+ async function fetchLyricInfo(songId) {
130
+ const searchParams = new URLSearchParams({ songId });
131
+ const headers = await buildLyricHeaders(songId);
132
+ try {
133
+ const response = await axios.get(`${LYRIC_URL}?${searchParams.toString()}`, {
134
+ timeout: HTTP_TIMEOUT_MS,
135
+ headers
136
+ });
137
+ return response.data;
138
+ } catch (error) {
139
+ logger.error('Melon lyricInfo fetch failed', { error, songId });
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ function toLyricStrings(lyricInfo) {
145
+ if (!lyricInfo) return { plain: null, synced: null };
146
+ if (typeof lyricInfo.lyric === 'string' && lyricInfo.lyric.trim()) {
147
+ const plain = lyricInfo.lyric
148
+ .replace(/<BR\s*\/?>/gi, '\n')
149
+ .replace(/&#39;/g, "'")
150
+ .replace(/&amp;/g, '&')
151
+ .replace(/&quot;/g, '"')
152
+ .replace(/&lt;/g, '<')
153
+ .replace(/&gt;/g, '>')
154
+ .trim();
155
+ return { plain: plain || null, synced: null };
156
+ }
157
+ if (!lyricInfo.lyricList) return { plain: null, synced: null };
158
+ const list = lyricInfo.lyricList;
159
+ const plain = list
160
+ .map((item) => item.lyric || '')
161
+ .filter(Boolean)
162
+ .join('\n');
163
+ return { plain: plain || null, synced: null };
164
+ }
165
+
166
+ export async function searchMelon(track) {
167
+ const pageHtml = await fetchSearchPage(track);
168
+ return parseSearchPage(pageHtml).map((record) =>
169
+ normalizeLyricRecord({
170
+ provider: 'melon',
171
+ id: record.songId,
172
+ trackName: record.title,
173
+ artistName: record.artist,
174
+ albumName: record.album,
175
+ duration: null,
176
+ plainLyrics: null,
177
+ syncedLyrics: null,
178
+ sourceUrl: `https://www.melon.com/song/detail.htm?songId=${record.songId}`,
179
+ confidence: 0.5,
180
+ synced: false,
181
+ status: 'ok',
182
+ raw: record
183
+ })
184
+ );
185
+ }
186
+
187
+ export async function fetchFromMelon(track) {
188
+ const candidates = await searchMelon(track);
189
+ const primary = candidates[0];
190
+ 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;
201
+ }
@@ -0,0 +1,165 @@
1
+ import axios from 'axios';
2
+
3
+ import { normalizeLyricRecord } from '../provider-result-schema.js';
4
+ import { assertEnv } from '../utils/config.js';
5
+ import { createLogger } from '../utils/logger.js';
6
+ import {
7
+ getMusixmatchToken,
8
+ invalidateMusixmatchToken
9
+ } from '../utils/tokens/musixmatch-token-manager.js';
10
+
11
+ const BASE_URL = 'https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get';
12
+ const HTTP_TIMEOUT_MS = Number(process.env.MR_MAGIC_HTTP_TIMEOUT_MS || 10000);
13
+ const MOZILLA_USER_AGENT =
14
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36';
15
+ const DEFAULT_HEADERS = {
16
+ authority: 'apic-desktop.musixmatch.com',
17
+ 'User-Agent': MOZILLA_USER_AGENT
18
+ };
19
+
20
+ const logger = createLogger('provider:musixmatch');
21
+
22
+ function buildParams(track, token) {
23
+ const durationSeconds = track.duration ? Math.round(track.duration / 1000) : '';
24
+ return new URLSearchParams({
25
+ format: 'json',
26
+ namespace: 'lyrics_richsynched',
27
+ subtitle_format: 'mxm',
28
+ app_id: 'web-desktop-app-v1.0',
29
+ q_album: track.album || '',
30
+ q_artist: track.artist || '',
31
+ q_artists: track.artist || '',
32
+ q_track: track.title || '',
33
+ track_spotify_id: track.uri || '',
34
+ q_duration: durationSeconds,
35
+ f_subtitle_length: durationSeconds ? Math.floor(durationSeconds) : '',
36
+ usertoken: token || ''
37
+ });
38
+ }
39
+
40
+ function normalizeBody(body) {
41
+ const matcher = body['matcher.track.get']?.message?.body;
42
+ if (!matcher) {
43
+ return null;
44
+ }
45
+
46
+ const meta = matcher.track || {};
47
+ const lyricsBody = body['track.lyrics.get']?.message?.body?.lyrics?.lyrics_body || '';
48
+ const subtitlesRoot = body['track.subtitles.get']?.message?.body;
49
+ const subtitleEntry =
50
+ subtitlesRoot?.subtitle_list?.find((entry) => {
51
+ if (!entry) return false;
52
+ if (entry.subtitle_body) return true;
53
+ if (entry.subtitle?.subtitle_body) return true;
54
+ return Boolean(entry.subtitle_id || entry.subtitle?.subtitle_id);
55
+ }) ?? null;
56
+ let syncedLines = [];
57
+ const resolveBody = () => {
58
+ if (!subtitleEntry) return null;
59
+ if (subtitleEntry.subtitle_body) return subtitleEntry.subtitle_body;
60
+ if (subtitleEntry.subtitle?.subtitle_body) return subtitleEntry.subtitle.subtitle_body;
61
+ const subtitleId = subtitleEntry.subtitle_id || subtitleEntry.subtitle?.subtitle_id;
62
+ if (!subtitleId) return null;
63
+ const rawSubtitle = subtitlesRoot?.subtitle?.find((entry) => entry.subtitle_id === subtitleId);
64
+ return rawSubtitle?.subtitle_body ?? null;
65
+ };
66
+
67
+ const subtitleBody = resolveBody();
68
+ if (subtitleBody) {
69
+ try {
70
+ syncedLines = JSON.parse(subtitleBody);
71
+ } catch (error) {
72
+ logger.warn('Failed to parse Musixmatch subtitle_body', { error });
73
+ syncedLines = [];
74
+ }
75
+ }
76
+
77
+ const syncedLyrics = syncedLines
78
+ .map((item) => {
79
+ const time = item?.time || {};
80
+ return `[${time.minutes.toString().padStart(2, '0')}:${time.seconds.toString().padStart(2, '0')}.${(
81
+ time.hundredths || 0
82
+ )
83
+ .toString()
84
+ .padStart(2, '0')}] ${item.text}`.trim();
85
+ })
86
+ .join('\n');
87
+
88
+ const plainLyrics = lyricsBody.split('\n').filter(Boolean).join('\n');
89
+ const sourceUrl = meta.share_url || '';
90
+
91
+ return normalizeLyricRecord({
92
+ provider: 'musixmatch',
93
+ id: meta.track_id,
94
+ trackName: meta.track_name,
95
+ artistName: meta.artist_name,
96
+ albumName: meta.album_name,
97
+ duration: meta.track_length || null,
98
+ plainLyrics: plainLyrics || null,
99
+ syncedLyrics: syncedLyrics || null,
100
+ sourceUrl,
101
+ confidence: 1,
102
+ synced: Boolean(syncedLyrics),
103
+ status: 'ok',
104
+ raw: body
105
+ });
106
+ }
107
+
108
+ async function macroRequest(track) {
109
+ await ensureMusixmatchToken();
110
+ let token = await getMusixmatchToken();
111
+ const attempt = async (tok) => {
112
+ const params = buildParams(track, tok);
113
+ const response = await axios.get(`${BASE_URL}?${params.toString()}`, {
114
+ timeout: HTTP_TIMEOUT_MS,
115
+ headers: DEFAULT_HEADERS
116
+ });
117
+ return response.data?.message?.body?.macro_calls || response.data?.message?.body || {};
118
+ };
119
+
120
+ try {
121
+ return await attempt(token);
122
+ } catch (error) {
123
+ if (error.response?.status === 401 || error.response?.status === 403) {
124
+ logger.warn('Musixmatch token rejected, invalidating', { status: error.response.status });
125
+ invalidateMusixmatchToken();
126
+ token = await getMusixmatchToken();
127
+ if (token) {
128
+ try {
129
+ return await attempt(token);
130
+ } catch (retryError) {
131
+ logger.error('Musixmatch retry failed', { error: retryError });
132
+ }
133
+ }
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ async function ensureMusixmatchToken() {
140
+ const token = await getMusixmatchToken();
141
+ if (!token) {
142
+ assertEnv(['MUSIXMATCH_TOKEN']);
143
+ }
144
+ }
145
+
146
+ export async function fetchFromMusixmatch(track) {
147
+ try {
148
+ const body = await macroRequest(track);
149
+ const record = normalizeBody(body);
150
+ return record;
151
+ } catch (error) {
152
+ logger.error('Musixmatch request failed', { error });
153
+ return null;
154
+ }
155
+ }
156
+
157
+ export async function searchMusixmatch(track) {
158
+ const record = await fetchFromMusixmatch(track);
159
+ return record ? [record] : [];
160
+ }
161
+
162
+ export async function checkMusixmatchTokenReady() {
163
+ const token = await getMusixmatchToken();
164
+ return Boolean(token);
165
+ }
@@ -0,0 +1,246 @@
1
+ import '../utils/config.js';
2
+
3
+ import { createLogger } from '../utils/logger.js';
4
+
5
+ const logger = createLogger('airtable-writer');
6
+
7
+ const AIRTABLE_API_BASE = 'https://api.airtable.com/v0';
8
+
9
+ /**
10
+ * Returns the configured Airtable personal access token, or throws if missing.
11
+ */
12
+ function getAirtableToken() {
13
+ const token = process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN;
14
+ if (!token) {
15
+ throw new Error(
16
+ 'AIRTABLE_PERSONAL_ACCESS_TOKEN is not set. ' +
17
+ 'Add it to your .env file. See .env.example for details.'
18
+ );
19
+ }
20
+ return token;
21
+ }
22
+
23
+ /**
24
+ * Build the Airtable REST API URL for a table.
25
+ * @param {string} baseId
26
+ * @param {string} tableId
27
+ * @returns {string}
28
+ */
29
+ function tableUrl(baseId, tableId) {
30
+ return `${AIRTABLE_API_BASE}/${encodeURIComponent(baseId)}/${encodeURIComponent(tableId)}`;
31
+ }
32
+
33
+ /**
34
+ * Make a request to the Airtable REST API.
35
+ * @param {string} method
36
+ * @param {string} url
37
+ * @param {object} [body]
38
+ * @returns {Promise<object>}
39
+ */
40
+ async function airtableRequest(method, url, body) {
41
+ const token = getAirtableToken();
42
+ const init = {
43
+ method,
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ 'Content-Type': 'application/json'
47
+ }
48
+ };
49
+ if (body != null) {
50
+ init.body = JSON.stringify(body);
51
+ }
52
+
53
+ const timeoutMs = Number(process.env.MR_MAGIC_HTTP_TIMEOUT_MS || 10000);
54
+ let controller;
55
+ let timeoutId;
56
+ if (typeof AbortController !== 'undefined') {
57
+ controller = new AbortController();
58
+ init.signal = controller.signal;
59
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
60
+ }
61
+
62
+ let res;
63
+ try {
64
+ res = await fetch(url, init);
65
+ } finally {
66
+ if (timeoutId != null) clearTimeout(timeoutId);
67
+ }
68
+
69
+ const text = await res.text();
70
+ let json;
71
+ try {
72
+ json = JSON.parse(text);
73
+ } catch {
74
+ json = { _rawBody: text };
75
+ }
76
+
77
+ if (!res.ok) {
78
+ const message =
79
+ json?.error?.message ||
80
+ json?.message ||
81
+ json?.error ||
82
+ text ||
83
+ `Airtable API error ${res.status}`;
84
+ const err = new Error(`Airtable ${res.status}: ${message}`);
85
+ err.status = res.status;
86
+ err.airtableError = json;
87
+ throw err;
88
+ }
89
+
90
+ return json;
91
+ }
92
+
93
+ /**
94
+ * Create a new record in an Airtable table.
95
+ *
96
+ * @param {object} opts
97
+ * @param {string} opts.baseId
98
+ * @param {string} opts.tableId
99
+ * @param {Record<string, unknown>} opts.fields - { fieldIdOrName: value, ... }
100
+ * @returns {Promise<{ id: string, createdTime: string, fields: Record<string, unknown> }>}
101
+ */
102
+ export async function createAirtableRecord({ baseId, tableId, fields }) {
103
+ logger.debug('Creating Airtable record', { baseId, tableId, fieldKeys: Object.keys(fields) });
104
+ const url = tableUrl(baseId, tableId);
105
+ const result = await airtableRequest('POST', url, { records: [{ fields }] });
106
+ const record = result?.records?.[0];
107
+ if (!record) {
108
+ throw new Error('Airtable create returned no record');
109
+ }
110
+ logger.info('Airtable record created', { baseId, tableId, recordId: record.id });
111
+ return record;
112
+ }
113
+
114
+ /**
115
+ * Update an existing record in an Airtable table (PATCH — only named fields are changed).
116
+ *
117
+ * @param {object} opts
118
+ * @param {string} opts.baseId
119
+ * @param {string} opts.tableId
120
+ * @param {string} opts.recordId
121
+ * @param {Record<string, unknown>} opts.fields - { fieldIdOrName: value, ... }
122
+ * @returns {Promise<{ id: string, createdTime: string, fields: Record<string, unknown> }>}
123
+ */
124
+ export async function updateAirtableRecord({ baseId, tableId, recordId, fields }) {
125
+ logger.debug('Updating Airtable record', {
126
+ baseId,
127
+ tableId,
128
+ recordId,
129
+ fieldKeys: Object.keys(fields)
130
+ });
131
+ const url = `${tableUrl(baseId, tableId)}`;
132
+ const result = await airtableRequest('PATCH', url, {
133
+ records: [{ id: recordId, fields }]
134
+ });
135
+ const record = result?.records?.[0];
136
+ if (!record) {
137
+ throw new Error('Airtable update returned no record');
138
+ }
139
+ logger.info('Airtable record updated', { baseId, tableId, recordId: record.id });
140
+ return record;
141
+ }
142
+
143
+ /**
144
+ * Push catalog fields to Airtable.
145
+ *
146
+ * Resolves field values from the provided `fieldValues` map plus an optional
147
+ * `lyricsText` string that arrives server-side (never through the LLM).
148
+ *
149
+ * If `recordId` is supplied → PATCH update.
150
+ * If `recordId` is omitted → POST create.
151
+ * If `splitLyricsUpdate` is true → create the record without lyrics first,
152
+ * then PATCH lyrics in a second call (safe for very large lyric payloads).
153
+ *
154
+ * @param {object} opts
155
+ * @param {string} opts.baseId
156
+ * @param {string} opts.tableId
157
+ * @param {string} [opts.recordId]
158
+ * @param {Record<string, string>} opts.fieldValues - { fieldId: scalarValue }
159
+ * @param {string} [opts.lyricsFieldId] - field ID where lyrics should go
160
+ * @param {string} [opts.lyricsText] - raw lyrics text (fetched server-side)
161
+ * @param {boolean} [opts.splitLyricsUpdate] - force two-step create+update
162
+ * @returns {Promise<{ record: object, lyricsRecord?: object, steps: string[] }>}
163
+ */
164
+ export async function pushCatalogToAirtable({
165
+ baseId,
166
+ tableId,
167
+ recordId,
168
+ fieldValues = {},
169
+ lyricsFieldId,
170
+ lyricsText,
171
+ splitLyricsUpdate = false
172
+ }) {
173
+ const steps = [];
174
+ const hasLyrics = lyricsFieldId && lyricsText;
175
+
176
+ // ------------------------------------------------------------------
177
+ // Build the non-lyrics fields object
178
+ // ------------------------------------------------------------------
179
+ const baseFields = { ...fieldValues };
180
+
181
+ // ------------------------------------------------------------------
182
+ // Decide flow
183
+ // ------------------------------------------------------------------
184
+ const isUpdate = Boolean(recordId);
185
+ const forceSplit = splitLyricsUpdate || false;
186
+
187
+ let record;
188
+
189
+ if (isUpdate) {
190
+ // --- UPDATE existing record ---
191
+ if (hasLyrics && !forceSplit) {
192
+ // Include lyrics in the single PATCH
193
+ const fields = { ...baseFields, [lyricsFieldId]: lyricsText };
194
+ record = await updateAirtableRecord({ baseId, tableId, recordId, fields });
195
+ steps.push(`updated record ${record.id} with all fields including lyrics`);
196
+ return { record, steps };
197
+ }
198
+
199
+ if (hasLyrics && forceSplit) {
200
+ // Two-step: update base fields first, then lyrics
201
+ record = await updateAirtableRecord({ baseId, tableId, recordId, fields: baseFields });
202
+ steps.push(`updated record ${record.id} base fields`);
203
+ const lyricsRecord = await updateAirtableRecord({
204
+ baseId,
205
+ tableId,
206
+ recordId: record.id,
207
+ fields: { [lyricsFieldId]: lyricsText }
208
+ });
209
+ steps.push(`updated record ${lyricsRecord.id} lyrics field`);
210
+ return { record, lyricsRecord, steps };
211
+ }
212
+
213
+ // No lyrics — plain update
214
+ record = await updateAirtableRecord({ baseId, tableId, recordId, fields: baseFields });
215
+ steps.push(`updated record ${record.id} base fields`);
216
+ return { record, steps };
217
+ }
218
+
219
+ // --- CREATE new record ---
220
+ if (hasLyrics && !forceSplit) {
221
+ // Single create with all fields
222
+ const fields = { ...baseFields, [lyricsFieldId]: lyricsText };
223
+ record = await createAirtableRecord({ baseId, tableId, fields });
224
+ steps.push(`created record ${record.id} with all fields including lyrics`);
225
+ return { record, steps };
226
+ }
227
+
228
+ if (hasLyrics && forceSplit) {
229
+ // Two-step: create without lyrics, then PATCH lyrics
230
+ record = await createAirtableRecord({ baseId, tableId, fields: baseFields });
231
+ steps.push(`created record ${record.id} without lyrics`);
232
+ const lyricsRecord = await updateAirtableRecord({
233
+ baseId,
234
+ tableId,
235
+ recordId: record.id,
236
+ fields: { [lyricsFieldId]: lyricsText }
237
+ });
238
+ steps.push(`updated record ${lyricsRecord.id} lyrics field`);
239
+ return { record, lyricsRecord, steps };
240
+ }
241
+
242
+ // No lyrics — plain create
243
+ record = await createAirtableRecord({ baseId, tableId, fields: baseFields });
244
+ steps.push(`created record ${record.id} base fields only`);
245
+ return { record, steps };
246
+ }