@mkterswingman/yt-mcp 0.2.2 → 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 +0 -0
- package/dist/cli/setup.js +64 -28
- package/dist/cli/setupCookies.js +154 -21
- package/dist/tools/subtitles.js +136 -23
- package/dist/utils/ytdlpPath.d.ts +4 -0
- package/dist/utils/ytdlpPath.js +15 -1
- package/package.json +1 -1
- package/scripts/download-ytdlp.mjs +28 -3
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
|
|
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(` ✅
|
|
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
|
-
//
|
|
89
|
-
|
|
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: "
|
|
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(
|
|
118
|
+
console.log(`\r ✅ Subtitle engine ready `);
|
|
98
119
|
}
|
|
99
120
|
else {
|
|
100
|
-
console.log(
|
|
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(
|
|
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
|
|
241
|
+
const mcpArgs = "npx @mkterswingman/yt-mcp@latest serve";
|
|
223
242
|
let registered = false;
|
|
224
243
|
const cliCandidates = [
|
|
225
|
-
//
|
|
226
|
-
{ bin: "claude-internal", label: "Claude Code (internal)"
|
|
227
|
-
|
|
228
|
-
{ bin: "
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
{ bin: "codex", label: "Codex CLI / Codex App"
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
{ bin: "
|
|
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
|
-
|
|
239
|
-
|
|
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) {
|
package/dist/cli/setupCookies.js
CHANGED
|
@@ -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
|
|
16
|
-
}
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
30
|
-
|
|
31
|
-
await page.waitForTimeout(3000);
|
|
106
|
+
await page.goto("https://www.youtube.com");
|
|
107
|
+
await page.waitForLoadState("domcontentloaded");
|
|
32
108
|
}
|
|
33
109
|
catch {
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/subtitles.js
CHANGED
|
@@ -32,43 +32,156 @@ function randomSleep(min, max) {
|
|
|
32
32
|
return sleep(Math.random() * (max - min) + min);
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
35
|
+
* Decode common HTML entities found in YouTube auto-captions.
|
|
36
|
+
*/
|
|
37
|
+
function decodeHtmlEntities(text) {
|
|
38
|
+
return text
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/&/g, "&")
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/'/g, "'")
|
|
44
|
+
.replace(/ /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
|
|
41
|
-
|
|
42
|
-
let
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
120
|
+
currentTs = parseTimestamp(trimmed);
|
|
121
|
+
currentTextLines = [];
|
|
54
122
|
}
|
|
55
|
-
else if (trimmed &&
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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);
|
|
@@ -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;
|
package/dist/utils/ytdlpPath.js
CHANGED
|
@@ -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:
|
|
69
|
+
timeout: 30_000,
|
|
56
70
|
})
|
|
57
71
|
.toString()
|
|
58
72
|
.trim();
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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(
|