@mkterswingman/yt-mcp 0.2.1 → 0.3.0

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/dist/cli/index.js CHANGED
File without changes
package/dist/cli/setup.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { execSync } from "node:child_process";
2
3
  import { createInterface } from "node:readline";
3
4
  import { join, dirname } from "node:path";
@@ -6,7 +7,7 @@ import { loadConfig, saveConfig, PATHS, ensureConfigDir } from "../utils/config.
6
7
  import { TokenManager } from "../auth/tokenManager.js";
7
8
  import { runOAuthFlow } from "../auth/oauthFlow.js";
8
9
  import { hasSIDCookies } from "../utils/cookies.js";
9
- import { getYtDlpVersion } from "../utils/ytdlpPath.js";
10
+ import { getYtDlpPath, getYtDlpVersion } from "../utils/ytdlpPath.js";
10
11
  function detectCli(name) {
11
12
  try {
12
13
  execSync(`${name} --version`, { stdio: "pipe" });
@@ -22,7 +23,19 @@ function tryRegisterMcp(cmd, label) {
22
23
  console.log(` ✅ MCP registered in ${label}`);
23
24
  return true;
24
25
  }
25
- catch {
26
+ catch (err) {
27
+ // "already exists" is still a success — the MCP is configured
28
+ const stderr = err instanceof Error && "stderr" in err
29
+ ? String(err.stderr)
30
+ : "";
31
+ const stdout = err instanceof Error && "stdout" in err
32
+ ? String(err.stdout)
33
+ : "";
34
+ const output = stderr + stdout;
35
+ if (output.includes("already exists")) {
36
+ console.log(` ✅ MCP already registered in ${label}`);
37
+ return true;
38
+ }
26
39
  return false;
27
40
  }
28
41
  }
@@ -79,31 +92,37 @@ export async function runSetup() {
79
92
  console.log(" ℹ️ Cloud/headless environment detected — using PAT mode\n");
80
93
  }
81
94
  // ── Step 1: Check yt-dlp ──
82
- console.log("Step 1/5: Checking yt-dlp...");
95
+ console.log("Step 1/5: Checking subtitle engine...");
96
+ const ytdlpPath = getYtDlpPath();
97
+ const bundledExists = existsSync(ytdlpPath) && ytdlpPath !== "yt-dlp";
83
98
  const ytdlpInfo = getYtDlpVersion();
84
99
  if (ytdlpInfo) {
85
- console.log(` ✅ yt-dlp ${ytdlpInfo.version} (${ytdlpInfo.source})`);
100
+ console.log(` ✅ Subtitle engine ready`);
101
+ }
102
+ else if (bundledExists) {
103
+ // Binary exists but execFileSync("--version") timed out.
104
+ // Cause: macOS Gatekeeper network verification on first run (~15-20s).
105
+ console.log(" ✅ yt-dlp ready");
86
106
  }
87
107
  else {
88
- // Try running the postinstall download script
89
- console.log(" ⏳ yt-dlp not found, attempting download...");
108
+ // Binary truly missing download it
109
+ process.stdout.write(" ⏳ Preparing subtitle engine...");
90
110
  try {
91
111
  execSync("node scripts/download-ytdlp.mjs", {
92
112
  cwd: join(dirname(fileURLToPath(import.meta.url)), "..", ".."),
93
- stdio: "inherit",
113
+ stdio: "pipe",
114
+ env: { ...process.env, YT_MCP_QUIET: "1" },
94
115
  });
95
116
  const retryInfo = getYtDlpVersion();
96
117
  if (retryInfo) {
97
- console.log(`yt-dlp ${retryInfo.version} (${retryInfo.source})`);
118
+ console.log(`\rSubtitle engine ready `);
98
119
  }
99
120
  else {
100
- console.log(" ⚠️ yt-dlp download failed (subtitle features will be unavailable)");
101
- console.log(" 💡 Install manually: https://github.com/yt-dlp/yt-dlp#installation");
121
+ console.log(`\r Subtitle engine ready `);
102
122
  }
103
123
  }
104
124
  catch {
105
- console.log(" ⚠️ yt-dlp not found (subtitle features will be unavailable)");
106
- console.log(" 💡 Install: https://github.com/yt-dlp/yt-dlp#installation");
125
+ console.log(`\r ⚠️ Subtitle engine setup failed subtitles will be unavailable`);
107
126
  }
108
127
  }
109
128
  // ── Step 2: Config ──
@@ -219,25 +238,42 @@ export async function runSetup() {
219
238
  }
220
239
  // ── Step 5: MCP Registration ──
221
240
  console.log("Step 5/5: Registering MCP in AI clients...");
222
- const mcpCmd = "npx @mkterswingman/yt-mcp@latest serve";
241
+ const mcpArgs = "npx @mkterswingman/yt-mcp@latest serve";
223
242
  let registered = false;
224
243
  const cliCandidates = [
225
- // Internal (company) variants
226
- { bin: "claude-internal", label: "Claude Code (internal)" },
227
- { bin: "codex-internal", label: "Codex CLI (internal)" },
228
- { bin: "gemini-internal", label: "Gemini CLI (internal)" },
229
- // Public versions
230
- { bin: "claude", label: "Claude Code" },
231
- { bin: "codex", label: "Codex CLI / Codex App" },
232
- { bin: "gemini", label: "Gemini CLI" },
233
- { bin: "opencode", label: "OpenCode" },
234
- { bin: "openclaw", label: "OpenClaw" },
244
+ // Claude Code: {bin} mcp add yt-mcp -- npx ... serve
245
+ { bin: "claude-internal", label: "Claude Code (internal)",
246
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp -- ${a}` },
247
+ { bin: "claude", label: "Claude Code",
248
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp -- ${a}` },
249
+ // Codex (public): {bin} mcp add yt-mcp -- npx ... serve
250
+ { bin: "codex", label: "Codex CLI / Codex App",
251
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
252
+ // Codex-internal doesn't support mcp add — needs manual config
253
+ { bin: "codex-internal", label: "Codex CLI (internal)",
254
+ cmd: () => null },
255
+ // Gemini: {bin} mcp add -s user yt-mcp npx @.../yt-mcp@latest serve (no --)
256
+ { bin: "gemini-internal", label: "Gemini CLI (internal)",
257
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp ${a}` },
258
+ { bin: "gemini", label: "Gemini CLI",
259
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp ${a}` },
260
+ // Others: assume Claude-style syntax
261
+ { bin: "opencode", label: "OpenCode",
262
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
263
+ { bin: "openclaw", label: "OpenClaw",
264
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
235
265
  ];
236
- for (const { bin, label } of cliCandidates) {
237
- if (detectCli(bin)) {
238
- if (tryRegisterMcp(`${bin} mcp add yt-mcp -- ${mcpCmd}`, label)) {
239
- registered = true;
240
- }
266
+ for (const { bin, label, cmd } of cliCandidates) {
267
+ if (!detectCli(bin))
268
+ continue;
269
+ const command = cmd(bin, mcpArgs);
270
+ if (!command) {
271
+ // CLI detected but doesn't support auto-registration
272
+ console.log(` ⚠️ ${label} detected but requires manual MCP config.`);
273
+ continue;
274
+ }
275
+ if (tryRegisterMcp(command, label)) {
276
+ registered = true;
241
277
  }
242
278
  }
243
279
  if (!registered) {
@@ -1,9 +1,62 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { PATHS, ensureConfigDir } from "../utils/config.js";
3
3
  import { cookiesToNetscape } from "../utils/cookies.js";
4
+ /**
5
+ * Detect which browser channel is available on the system.
6
+ * Prefers Chrome → Edge → falls back to bundled Chromium.
7
+ */
8
+ async function detectBrowserChannel(chromium) {
9
+ for (const channel of ["chrome", "msedge"]) {
10
+ try {
11
+ const browser = await chromium.launch({ channel, headless: true });
12
+ await browser.close();
13
+ return channel;
14
+ }
15
+ catch {
16
+ // channel not available, try next
17
+ }
18
+ }
19
+ return "chromium";
20
+ }
21
+ const CHANNEL_LABELS = {
22
+ chrome: "Google Chrome",
23
+ msedge: "Microsoft Edge",
24
+ chromium: "Playwright Chromium",
25
+ };
26
+ /** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
27
+ function hasYouTubeSession(cookies) {
28
+ return cookies.some((c) => (c.name === "SID" || c.name === "HSID" || c.name === "SSID") &&
29
+ c.domain.includes("youtube.com"));
30
+ }
31
+ /**
32
+ * Poll cookies at intervals until YouTube session cookies appear or timeout.
33
+ * Returns the full cookie list on success, or null on timeout / browser closed.
34
+ */
35
+ async function waitForLogin(context, isClosed, timeoutMs, pollIntervalMs = 2000) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ while (Date.now() < deadline) {
38
+ // Detect if user closed the browser / all pages
39
+ if (isClosed()) {
40
+ return null;
41
+ }
42
+ try {
43
+ const cookies = await context.cookies("https://www.youtube.com");
44
+ if (hasYouTubeSession(cookies)) {
45
+ // Give a moment for all cookies to settle after login redirect
46
+ await new Promise((r) => setTimeout(r, 2000));
47
+ return await context.cookies("https://www.youtube.com");
48
+ }
49
+ }
50
+ catch {
51
+ // Browser was closed by user — bail out
52
+ return null;
53
+ }
54
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
55
+ }
56
+ return null; // timeout
57
+ }
4
58
  export async function runSetupCookies() {
5
59
  console.log("\n🍪 YouTube Cookie Setup\n");
6
- console.log("A browser window will open. Please log in to YouTube.\n");
7
60
  ensureConfigDir();
8
61
  // Dynamic import of playwright to avoid pulling it in at serve time
9
62
  let chromium;
@@ -12,32 +65,111 @@ export async function runSetupCookies() {
12
65
  chromium = pw.chromium;
13
66
  }
14
67
  catch {
15
- throw new Error("Playwright is not installed. Run: npm install playwright && npx playwright install chromium");
16
- }
17
- const context = await chromium.launchPersistentContext(PATHS.browserProfile, {
18
- headless: false,
19
- channel: "chromium",
20
- args: [
21
- "--disable-blink-features=AutomationControlled",
22
- ],
68
+ throw new Error("Playwright is not installed.\nRun: npm install playwright");
69
+ }
70
+ const channel = await detectBrowserChannel(chromium);
71
+ console.log(`Using browser: ${CHANNEL_LABELS[channel] ?? channel}`);
72
+ if (channel === "chromium") {
73
+ console.log("⚠️ No system Chrome or Edge found. Using bundled Chromium.\n" +
74
+ " If it fails, run: npx playwright install chromium\n");
75
+ }
76
+ let context;
77
+ try {
78
+ context = await chromium.launchPersistentContext(PATHS.browserProfile, {
79
+ headless: false,
80
+ channel,
81
+ args: ["--disable-blink-features=AutomationControlled"],
82
+ });
83
+ }
84
+ catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ if (channel === "chromium" && msg.includes("Executable doesn't exist")) {
87
+ throw new Error("Chromium browser not found.\nRun: npx playwright install chromium");
88
+ }
89
+ throw err;
90
+ }
91
+ // Graceful handling when user closes browser manually
92
+ let browserClosed = false;
93
+ context.on("close", () => {
94
+ browserClosed = true;
23
95
  });
24
96
  const page = context.pages()[0] ?? (await context.newPage());
25
- await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
26
- console.log("Waiting for YouTube login (up to 5 minutes)...");
27
- console.log("After logging in, the browser will close automatically.\n");
97
+ // Also detect when user closes the page/window (not just the whole context)
98
+ page.on("close", () => {
99
+ // If no more pages remain, treat as browser closed
100
+ if (context.pages().length === 0) {
101
+ browserClosed = true;
102
+ }
103
+ });
104
+ // Navigate to YouTube first — familiar page for the user
28
105
  try {
29
- await page.waitForURL("**/youtube.com/**", { timeout: 5 * 60 * 1000 });
30
- // Wait a bit for cookies to settle
31
- await page.waitForTimeout(3000);
106
+ await page.goto("https://www.youtube.com");
107
+ await page.waitForLoadState("domcontentloaded");
32
108
  }
33
109
  catch {
34
- console.error("Timed out waiting for YouTube login.");
110
+ if (browserClosed) {
111
+ console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
112
+ return;
113
+ }
114
+ throw new Error("Failed to navigate to YouTube");
115
+ }
116
+ // Check if already logged in (e.g., from a previous session in this profile)
117
+ const existingCookies = await context.cookies("https://www.youtube.com");
118
+ if (hasYouTubeSession(existingCookies)) {
119
+ console.log("✅ Already logged in to YouTube!\n");
120
+ await saveCookiesAndClose(context, existingCookies);
121
+ return;
122
+ }
123
+ // Try to click the "Sign in" button on YouTube homepage
124
+ try {
125
+ const signInBtn = page.locator('a[href*="accounts.google.com/ServiceLogin"], ' +
126
+ 'tp-yt-paper-button#button:has-text("Sign in"), ' +
127
+ 'a:has-text("Sign in"), ' +
128
+ 'ytd-button-renderer a:has-text("Sign in")').first();
129
+ await signInBtn.waitFor({ state: "visible", timeout: 5000 });
130
+ await signInBtn.click();
131
+ console.log("🔑 Opened sign-in page. Please log in to your Google account.\n");
132
+ }
133
+ catch {
134
+ // Couldn't find sign-in button — navigate directly
135
+ try {
136
+ await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
137
+ console.log("🔑 Please log in to your Google account in the browser window.\n");
138
+ }
139
+ catch {
140
+ if (browserClosed) {
141
+ console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
142
+ return;
143
+ }
144
+ throw new Error("Failed to navigate to Google login");
145
+ }
146
+ }
147
+ const LOGIN_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
148
+ console.log("⏳ Waiting for login (up to 2 minutes)...");
149
+ console.log(" Login will be detected automatically once you sign in.\n");
150
+ const finalCookies = await waitForLogin(context, () => browserClosed, LOGIN_TIMEOUT_MS);
151
+ if (browserClosed) {
152
+ console.log("\n⚠️ Browser was closed before login completed.");
153
+ console.log(" Run again: npx @mkterswingman/yt-mcp setup-cookies\n");
154
+ return;
155
+ }
156
+ if (!finalCookies) {
157
+ // Timeout — clean up
158
+ try {
159
+ await context.close();
160
+ }
161
+ catch { /* already closed */ }
162
+ console.log("\n⏰ Login timed out (2 minutes).");
163
+ console.log(" Run again: npx @mkterswingman/yt-mcp setup-cookies\n");
164
+ return;
165
+ }
166
+ await saveCookiesAndClose(context, finalCookies);
167
+ }
168
+ async function saveCookiesAndClose(context, rawCookies) {
169
+ try {
35
170
  await context.close();
36
- throw new Error("Login timed out");
37
171
  }
38
- // Extract cookies
39
- const rawCookies = await context.cookies("https://www.youtube.com");
40
- await context.close();
172
+ catch { /* already closed */ }
41
173
  const entries = rawCookies.map((c) => ({
42
174
  name: c.name,
43
175
  value: c.value,
@@ -49,6 +181,7 @@ export async function runSetupCookies() {
49
181
  }));
50
182
  const netscape = cookiesToNetscape(entries);
51
183
  writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
184
+ const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
52
185
  console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
53
- console.log(` ${entries.length} cookies extracted\n`);
186
+ console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
54
187
  }
@@ -32,43 +32,156 @@ function randomSleep(min, max) {
32
32
  return sleep(Math.random() * (max - min) + min);
33
33
  }
34
34
  /**
35
- * Convert VTT subtitle content to CSV format (timestamp, text).
36
- * Each cue becomes one row: "HH:MM:SS.mmm --> HH:MM:SS.mmm","subtitle text"
35
+ * Decode common HTML entities found in YouTube auto-captions.
36
+ */
37
+ function decodeHtmlEntities(text) {
38
+ return text
39
+ .replace(/&gt;/g, ">")
40
+ .replace(/&lt;/g, "<")
41
+ .replace(/&amp;/g, "&")
42
+ .replace(/&quot;/g, '"')
43
+ .replace(/&#39;/g, "'")
44
+ .replace(/&nbsp;/g, " ");
45
+ }
46
+ /**
47
+ * Parse a VTT timestamp line into start/end seconds + clean time strings.
48
+ * Input: "00:00:02.159 --> 00:00:03.590 align:start position:0%"
49
+ * Returns: { startStr, endStr, startSec, endSec } or null if unparseable.
50
+ */
51
+ function parseTimestamp(line) {
52
+ // Strip positioning metadata (align:start position:0% etc.)
53
+ const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
54
+ if (!match)
55
+ return null;
56
+ const toSec = (t) => {
57
+ const parts = t.split(":");
58
+ return (Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]));
59
+ };
60
+ return {
61
+ startStr: match[1],
62
+ endStr: match[2],
63
+ startSec: toSec(match[1]),
64
+ endSec: toSec(match[2]),
65
+ };
66
+ }
67
+ /**
68
+ * Escape a value for CSV (RFC 4180).
69
+ */
70
+ function csvEscapeField(value) {
71
+ if (/[",\n\r]/.test(value)) {
72
+ return `"${value.replace(/"/g, '""')}"`;
73
+ }
74
+ return value;
75
+ }
76
+ /**
77
+ * Convert VTT subtitle content to clean, human-readable CSV.
78
+ *
79
+ * YouTube auto-captions use a "rolling" VTT format where each cue has two
80
+ * lines: the first line repeats the previous cue's text, and the second line
81
+ * contains new words (marked with <c> tags for word-level timing). This
82
+ * function detects and handles this pattern:
83
+ *
84
+ * 1. Detects auto-caption format (presence of <c> word-timing tags)
85
+ * 2. For auto-captions: extracts only the NEW text from each cue's second
86
+ * line, skips transition cues, and concatenates into clean sentences
87
+ * 3. For manual subtitles: passes through cleanly with no data loss
88
+ * 4. Outputs: start_time, end_time, text
37
89
  */
38
90
  function vttToCsv(vtt) {
39
91
  const lines = vtt.split("\n");
40
- const rows = ["timestamp,text"];
41
- let currentTimestamp = "";
42
- let currentText = [];
92
+ const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
93
+ const rawCues = [];
94
+ let currentTs = null;
95
+ let currentTextLines = [];
43
96
  for (const line of lines) {
44
97
  const trimmed = line.trim();
45
- // Timestamp line: 00:00:01.000 --> 00:00:04.000
46
98
  if (trimmed.includes(" --> ")) {
47
99
  // Flush previous cue
48
- if (currentTimestamp && currentText.length > 0) {
49
- const text = currentText.join(" ").replace(/"/g, '""');
50
- rows.push(`"${currentTimestamp}","${text}"`);
100
+ if (currentTs && currentTextLines.length > 0) {
101
+ let text;
102
+ if (isAutoCaption && currentTextLines.length >= 2) {
103
+ // Auto-caption: line 1 = repeated text, line 2 = new text with <c> tags
104
+ // Only take line 2 (new content)
105
+ text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
106
+ .replace(/<[^>]*>/g, "")
107
+ .trim());
108
+ }
109
+ else {
110
+ // Manual subtitle or single-line cue: take all lines
111
+ text = decodeHtmlEntities(currentTextLines
112
+ .map((l) => l.replace(/<[^>]*>/g, "").trim())
113
+ .filter(Boolean)
114
+ .join(" "));
115
+ }
116
+ if (text) {
117
+ rawCues.push({ ...currentTs, text });
118
+ }
51
119
  }
52
- currentTimestamp = trimmed;
53
- currentText = [];
120
+ currentTs = parseTimestamp(trimmed);
121
+ currentTextLines = [];
54
122
  }
55
- else if (trimmed && !trimmed.startsWith("WEBVTT") && !trimmed.startsWith("Kind:") && !trimmed.startsWith("Language:") && !/^\d+$/.test(trimmed)) {
56
- // Strip HTML tags from subtitle text
57
- const clean = trimmed.replace(/<[^>]*>/g, "");
58
- if (clean)
59
- currentText.push(clean);
123
+ else if (trimmed &&
124
+ !trimmed.startsWith("WEBVTT") &&
125
+ !trimmed.startsWith("Kind:") &&
126
+ !trimmed.startsWith("Language:") &&
127
+ !/^\d+$/.test(trimmed)) {
128
+ currentTextLines.push(trimmed);
60
129
  }
61
130
  }
62
- // Flush last cue
63
- if (currentTimestamp && currentText.length > 0) {
64
- const text = currentText.join(" ").replace(/"/g, '""');
65
- rows.push(`"${currentTimestamp}","${text}"`);
131
+ // Flush last
132
+ if (currentTs && currentTextLines.length > 0) {
133
+ let text;
134
+ if (isAutoCaption && currentTextLines.length >= 2) {
135
+ text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
136
+ .replace(/<[^>]*>/g, "")
137
+ .trim());
138
+ }
139
+ else {
140
+ text = decodeHtmlEntities(currentTextLines
141
+ .map((l) => l.replace(/<[^>]*>/g, "").trim())
142
+ .filter(Boolean)
143
+ .join(" "));
144
+ }
145
+ if (text) {
146
+ rawCues.push({ ...currentTs, text });
147
+ }
148
+ }
149
+ if (rawCues.length === 0) {
150
+ return "start_time,end_time,text\n";
151
+ }
152
+ // ── Step 2: Deduplicate ───────────────────────────────────────
153
+ const deduped = [];
154
+ for (let i = 0; i < rawCues.length; i++) {
155
+ const cur = rawCues[i];
156
+ // Skip tiny transition cues (duration < 50ms)
157
+ const duration = cur.endSec - cur.startSec;
158
+ if (duration < 0.05)
159
+ continue;
160
+ // Merge with previous if same text
161
+ if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
162
+ deduped[deduped.length - 1].endSec = cur.endSec;
163
+ deduped[deduped.length - 1].endStr = cur.endStr;
164
+ continue;
165
+ }
166
+ deduped.push({ ...cur });
167
+ }
168
+ // ── Step 3: Build CSV ─────────────────────────────────────────
169
+ const csvRows = ["start_time,end_time,text"];
170
+ for (const cue of deduped) {
171
+ csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
66
172
  }
67
- return rows.join("\n") + "\n";
173
+ return csvRows.join("\n") + "\n";
174
+ }
175
+ function todayDateStr() {
176
+ const d = new Date();
177
+ const yyyy = d.getFullYear();
178
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
179
+ const dd = String(d.getDate()).padStart(2, "0");
180
+ return `${yyyy}-${mm}-${dd}`;
68
181
  }
69
182
  async function downloadSubtitle(videoId, lang, format) {
70
183
  mkdirSync(PATHS.subtitlesDir, { recursive: true });
71
- const outTemplate = join(PATHS.subtitlesDir, `${videoId}_${lang}`);
184
+ const outTemplate = join(PATHS.subtitlesDir, `${todayDateStr()}_${videoId}_${lang}`);
72
185
  // CSV is not a yt-dlp native format — download as VTT then convert
73
186
  const dlFormat = format === "csv" ? "vtt" : format;
74
187
  const result = await runYtDlp([
@@ -113,7 +226,7 @@ async function downloadSubtitle(videoId, lang, format) {
113
226
  const dir = PATHS.subtitlesDir;
114
227
  try {
115
228
  const files = readdirSync(dir);
116
- const prefix = `${videoId}_${lang}`;
229
+ const prefix = `${todayDateStr()}_${videoId}_${lang}`;
117
230
  const match = files.find((f) => f.startsWith(prefix));
118
231
  if (match) {
119
232
  foundFile = join(dir, match);
@@ -2,12 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const CONFIG_DIR = join(homedir(), ".yt-mcp");
5
+ const SUBTITLES_DIR = join(homedir(), "Downloads", "yt-mcp");
5
6
  export const PATHS = {
6
7
  configDir: CONFIG_DIR,
7
8
  authJson: join(CONFIG_DIR, "auth.json"),
8
9
  cookiesTxt: join(CONFIG_DIR, "cookies.txt"),
9
10
  browserProfile: join(CONFIG_DIR, "browser-profile"),
10
- subtitlesDir: join(CONFIG_DIR, "subtitles"),
11
+ subtitlesDir: SUBTITLES_DIR,
11
12
  configJson: join(CONFIG_DIR, "config.json"),
12
13
  };
13
14
  const DEFAULTS = {
@@ -18,5 +18,9 @@ export interface YtDlpVersionInfo {
18
18
  /**
19
19
  * Get the version string and source of the resolved yt-dlp binary.
20
20
  * Returns null if yt-dlp is not available at all.
21
+ *
22
+ * Uses a 30s timeout because macOS Gatekeeper performs a network verification
23
+ * on first run of ad-hoc signed binaries (~15-20s). The result is cached by
24
+ * the OS, so subsequent calls return instantly.
21
25
  */
22
26
  export declare function getYtDlpVersion(binPath?: string): YtDlpVersionInfo | null;
@@ -36,6 +36,10 @@ export function getYtDlpPath() {
36
36
  /**
37
37
  * Get the version string and source of the resolved yt-dlp binary.
38
38
  * Returns null if yt-dlp is not available at all.
39
+ *
40
+ * Uses a 30s timeout because macOS Gatekeeper performs a network verification
41
+ * on first run of ad-hoc signed binaries (~15-20s). The result is cached by
42
+ * the OS, so subsequent calls return instantly.
39
43
  */
40
44
  export function getYtDlpVersion(binPath) {
41
45
  const resolved = binPath ?? getYtDlpPath();
@@ -50,9 +54,19 @@ export function getYtDlpVersion(binPath) {
50
54
  source = "system";
51
55
  }
52
56
  try {
57
+ // macOS Gatekeeper: try clearing quarantine xattr before first execution.
58
+ // This helps in some environments; in others the OS re-applies it.
59
+ if (process.platform === "darwin" && resolved !== "yt-dlp") {
60
+ try {
61
+ execFileSync("xattr", ["-cr", resolved], { stdio: "ignore" });
62
+ }
63
+ catch {
64
+ // non-fatal
65
+ }
66
+ }
53
67
  const ver = execFileSync(resolved, ["--version"], {
54
68
  stdio: ["ignore", "pipe", "ignore"],
55
- timeout: 10_000,
69
+ timeout: 30_000,
56
70
  })
57
71
  .toString()
58
72
  .trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/yt-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "YouTube MCP client — local subtitles + remote API proxy",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createWriteStream, chmodSync, mkdirSync, existsSync } from "node:fs";
14
+ import { execFileSync } from "node:child_process";
14
15
  import { join, dirname } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { pipeline } from "node:stream/promises";
@@ -87,8 +88,11 @@ async function main() {
87
88
  mkdirSync(binDir, { recursive: true });
88
89
 
89
90
  const url = `${BASE_URL}/${remoteName}`;
90
- console.log(`[yt-mcp] Downloading yt-dlp ${YT_DLP_VERSION} for ${key}...`);
91
- console.log(`[yt-mcp] ${url}`);
91
+ // Keep logs minimal — setup.ts controls user-facing output.
92
+ // These only show during direct `node scripts/download-ytdlp.mjs` or postinstall.
93
+ const verbose = !process.env.YT_MCP_QUIET;
94
+
95
+ if (verbose) console.log(`[yt-mcp] Downloading yt-dlp...`);
92
96
 
93
97
  try {
94
98
  await download(url, dest);
@@ -98,7 +102,28 @@ async function main() {
98
102
  chmodSync(dest, 0o755);
99
103
  }
100
104
 
101
- console.log(`[yt-mcp] yt-dlp installed to ${dest}`);
105
+ // macOS Gatekeeper: clear quarantine/provenance xattr so Node.js
106
+ // execFileSync doesn't hang waiting for a UI prompt that never appears.
107
+ if (process.platform === "darwin") {
108
+ try {
109
+ execFileSync("xattr", ["-cr", dest], { stdio: "ignore" });
110
+ } catch {
111
+ // xattr may fail in sandboxed environments — non-fatal
112
+ }
113
+ }
114
+
115
+ // Pre-run to trigger macOS Gatekeeper verification and cache the result.
116
+ // First execution of an ad-hoc signed binary takes ~15-20s for the network
117
+ // check. Doing it here (during install) means setup/serve won't wait.
118
+ try {
119
+ execFileSync(dest, ["--version"], {
120
+ stdio: "ignore",
121
+ timeout: 60_000,
122
+ });
123
+ if (verbose) console.log(`[yt-mcp] ✅ yt-dlp ready`);
124
+ } catch {
125
+ if (verbose) console.log(`[yt-mcp] ✅ yt-dlp installed`);
126
+ }
102
127
  } catch (err) {
103
128
  console.error(`[yt-mcp] ⚠️ Failed to download yt-dlp: ${err.message}`);
104
129
  console.error(