@mkterswingman/yt-mcp 0.2.2 → 0.3.1
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.d.ts +33 -0
- package/dist/cli/setupCookies.js +177 -35
- package/dist/tools/subtitles.js +207 -29
- package/dist/utils/cookieRefresh.d.ts +18 -0
- package/dist/utils/cookieRefresh.js +70 -0
- package/dist/utils/cookies.d.ts +7 -0
- package/dist/utils/cookies.js +50 -0
- package/dist/utils/ytdlpPath.d.ts +4 -0
- package/dist/utils/ytdlpPath.js +15 -1
- package/package.json +2 -2
- 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) {
|
|
@@ -1 +1,34 @@
|
|
|
1
|
+
export type PlaywrightCookie = {
|
|
2
|
+
name: string;
|
|
3
|
+
value: string;
|
|
4
|
+
domain: string;
|
|
5
|
+
path: string;
|
|
6
|
+
secure: boolean;
|
|
7
|
+
httpOnly: boolean;
|
|
8
|
+
expires: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Detect which browser channel is available on the system.
|
|
12
|
+
* Prefers Chrome → Edge → falls back to bundled Chromium.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectBrowserChannel(chromium: typeof import("playwright").chromium): Promise<string>;
|
|
15
|
+
export declare const CHANNEL_LABELS: Record<string, string>;
|
|
16
|
+
/** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
|
|
17
|
+
export declare function hasYouTubeSession(cookies: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
domain: string;
|
|
20
|
+
}>): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Save cookies to Netscape format file and close the browser context.
|
|
23
|
+
*/
|
|
24
|
+
export declare function saveCookiesAndClose(context: {
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}, rawCookies: PlaywrightCookie[], silent?: boolean): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
|
|
29
|
+
*/
|
|
30
|
+
export declare function saveCookiesToDisk(rawCookies: PlaywrightCookie[]): void;
|
|
31
|
+
/**
|
|
32
|
+
* Interactive cookie setup — opens a visible browser for user to log in.
|
|
33
|
+
*/
|
|
1
34
|
export declare function runSetupCookies(): Promise<void>;
|
package/dist/cli/setupCookies.js
CHANGED
|
@@ -1,43 +1,41 @@
|
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Detect which browser channel is available on the system.
|
|
6
|
+
* Prefers Chrome → Edge → falls back to bundled Chromium.
|
|
7
|
+
*/
|
|
8
|
+
export 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
|
+
}
|
|
13
18
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
return "chromium";
|
|
20
|
+
}
|
|
21
|
+
export 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
|
+
export 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
|
+
* Save cookies to Netscape format file and close the browser context.
|
|
33
|
+
*/
|
|
34
|
+
export async function saveCookiesAndClose(context, rawCookies, silent = false) {
|
|
28
35
|
try {
|
|
29
|
-
await page.waitForURL("**/youtube.com/**", { timeout: 5 * 60 * 1000 });
|
|
30
|
-
// Wait a bit for cookies to settle
|
|
31
|
-
await page.waitForTimeout(3000);
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
console.error("Timed out waiting for YouTube login.");
|
|
35
36
|
await context.close();
|
|
36
|
-
throw new Error("Login timed out");
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
const rawCookies = await context.cookies("https://www.youtube.com");
|
|
40
|
-
await context.close();
|
|
38
|
+
catch { /* already closed */ }
|
|
41
39
|
const entries = rawCookies.map((c) => ({
|
|
42
40
|
name: c.name,
|
|
43
41
|
value: c.value,
|
|
@@ -49,6 +47,150 @@ export async function runSetupCookies() {
|
|
|
49
47
|
}));
|
|
50
48
|
const netscape = cookiesToNetscape(entries);
|
|
51
49
|
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
if (!silent) {
|
|
51
|
+
const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
|
|
52
|
+
console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
|
|
53
|
+
console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
|
|
58
|
+
*/
|
|
59
|
+
export function saveCookiesToDisk(rawCookies) {
|
|
60
|
+
const entries = rawCookies.map((c) => ({
|
|
61
|
+
name: c.name,
|
|
62
|
+
value: c.value,
|
|
63
|
+
domain: c.domain,
|
|
64
|
+
path: c.path,
|
|
65
|
+
secure: c.secure,
|
|
66
|
+
httpOnly: c.httpOnly,
|
|
67
|
+
expires: c.expires,
|
|
68
|
+
}));
|
|
69
|
+
const netscape = cookiesToNetscape(entries);
|
|
70
|
+
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Poll cookies at intervals until YouTube session cookies appear or timeout.
|
|
74
|
+
* Returns the full cookie list on success, or null on timeout / browser closed.
|
|
75
|
+
*/
|
|
76
|
+
async function waitForLogin(context, isClosed, timeoutMs, pollIntervalMs = 2000) {
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
if (isClosed())
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
const cookies = await context.cookies("https://www.youtube.com");
|
|
83
|
+
if (hasYouTubeSession(cookies)) {
|
|
84
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
85
|
+
return await context.cookies("https://www.youtube.com");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Interactive cookie setup — opens a visible browser for user to log in.
|
|
97
|
+
*/
|
|
98
|
+
export async function runSetupCookies() {
|
|
99
|
+
console.log("\n🍪 YouTube Cookie Setup\n");
|
|
100
|
+
ensureConfigDir();
|
|
101
|
+
let chromium;
|
|
102
|
+
try {
|
|
103
|
+
const pw = await import("playwright");
|
|
104
|
+
chromium = pw.chromium;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error("Playwright is not installed.\nRun: npm install playwright");
|
|
108
|
+
}
|
|
109
|
+
const channel = await detectBrowserChannel(chromium);
|
|
110
|
+
console.log(`Using browser: ${CHANNEL_LABELS[channel] ?? channel}`);
|
|
111
|
+
if (channel === "chromium") {
|
|
112
|
+
console.log("⚠️ No system Chrome or Edge found. Using bundled Chromium.\n" +
|
|
113
|
+
" If it fails, run: npx playwright install chromium\n");
|
|
114
|
+
}
|
|
115
|
+
let context;
|
|
116
|
+
try {
|
|
117
|
+
context = await chromium.launchPersistentContext(PATHS.browserProfile, {
|
|
118
|
+
headless: false,
|
|
119
|
+
channel,
|
|
120
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
125
|
+
if (channel === "chromium" && msg.includes("Executable doesn't exist")) {
|
|
126
|
+
throw new Error("Chromium browser not found.\nRun: npx playwright install chromium");
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
let browserClosed = false;
|
|
131
|
+
context.on("close", () => { browserClosed = true; });
|
|
132
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
133
|
+
page.on("close", () => {
|
|
134
|
+
if (context.pages().length === 0) {
|
|
135
|
+
browserClosed = true;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await page.goto("https://www.youtube.com");
|
|
140
|
+
await page.waitForLoadState("domcontentloaded");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
if (browserClosed) {
|
|
144
|
+
console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
throw new Error("Failed to navigate to YouTube");
|
|
148
|
+
}
|
|
149
|
+
const existingCookies = await context.cookies("https://www.youtube.com");
|
|
150
|
+
if (hasYouTubeSession(existingCookies)) {
|
|
151
|
+
console.log("✅ Already logged in to YouTube!\n");
|
|
152
|
+
await saveCookiesAndClose(context, existingCookies);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const signInBtn = page.locator('a[href*="accounts.google.com/ServiceLogin"], ' +
|
|
157
|
+
'tp-yt-paper-button#button:has-text("Sign in"), ' +
|
|
158
|
+
'a:has-text("Sign in"), ' +
|
|
159
|
+
'ytd-button-renderer a:has-text("Sign in")').first();
|
|
160
|
+
await signInBtn.waitFor({ state: "visible", timeout: 5000 });
|
|
161
|
+
await signInBtn.click();
|
|
162
|
+
console.log("🔑 Opened sign-in page. Please log in to your Google account.\n");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
|
|
167
|
+
console.log("🔑 Please log in to your Google account in the browser window.\n");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
if (browserClosed) {
|
|
171
|
+
console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
throw new Error("Failed to navigate to Google login");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
178
|
+
console.log("⏳ Waiting for login (up to 2 minutes)...");
|
|
179
|
+
console.log(" Login will be detected automatically once you sign in.\n");
|
|
180
|
+
const finalCookies = await waitForLogin(context, () => browserClosed, LOGIN_TIMEOUT_MS);
|
|
181
|
+
if (browserClosed) {
|
|
182
|
+
console.log("\n⚠️ Browser was closed before login completed.");
|
|
183
|
+
console.log(" Run again: npx @mkterswingman/yt-mcp setup-cookies\n");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!finalCookies) {
|
|
187
|
+
try {
|
|
188
|
+
await context.close();
|
|
189
|
+
}
|
|
190
|
+
catch { /* already closed */ }
|
|
191
|
+
console.log("\n⏰ Login timed out (2 minutes).");
|
|
192
|
+
console.log(" Run again: npx @mkterswingman/yt-mcp setup-cookies\n");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await saveCookiesAndClose(context, finalCookies);
|
|
54
196
|
}
|
package/dist/tools/subtitles.js
CHANGED
|
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { PATHS } from "../utils/config.js";
|
|
5
5
|
import { runYtDlp } from "../utils/ytdlp.js";
|
|
6
|
-
import { hasSIDCookies } from "../utils/cookies.js";
|
|
6
|
+
import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
|
|
7
|
+
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
7
8
|
import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
|
|
8
9
|
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/yt-mcp setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
9
10
|
function toolOk(payload) {
|
|
@@ -32,43 +33,156 @@ function randomSleep(min, max) {
|
|
|
32
33
|
return sleep(Math.random() * (max - min) + min);
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
36
|
+
* Decode common HTML entities found in YouTube auto-captions.
|
|
37
|
+
*/
|
|
38
|
+
function decodeHtmlEntities(text) {
|
|
39
|
+
return text
|
|
40
|
+
.replace(/>/g, ">")
|
|
41
|
+
.replace(/</g, "<")
|
|
42
|
+
.replace(/&/g, "&")
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
.replace(/'/g, "'")
|
|
45
|
+
.replace(/ /g, " ");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse a VTT timestamp line into start/end seconds + clean time strings.
|
|
49
|
+
* Input: "00:00:02.159 --> 00:00:03.590 align:start position:0%"
|
|
50
|
+
* Returns: { startStr, endStr, startSec, endSec } or null if unparseable.
|
|
51
|
+
*/
|
|
52
|
+
function parseTimestamp(line) {
|
|
53
|
+
// Strip positioning metadata (align:start position:0% etc.)
|
|
54
|
+
const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
|
|
55
|
+
if (!match)
|
|
56
|
+
return null;
|
|
57
|
+
const toSec = (t) => {
|
|
58
|
+
const parts = t.split(":");
|
|
59
|
+
return (Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]));
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
startStr: match[1],
|
|
63
|
+
endStr: match[2],
|
|
64
|
+
startSec: toSec(match[1]),
|
|
65
|
+
endSec: toSec(match[2]),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Escape a value for CSV (RFC 4180).
|
|
70
|
+
*/
|
|
71
|
+
function csvEscapeField(value) {
|
|
72
|
+
if (/[",\n\r]/.test(value)) {
|
|
73
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Convert VTT subtitle content to clean, human-readable CSV.
|
|
79
|
+
*
|
|
80
|
+
* YouTube auto-captions use a "rolling" VTT format where each cue has two
|
|
81
|
+
* lines: the first line repeats the previous cue's text, and the second line
|
|
82
|
+
* contains new words (marked with <c> tags for word-level timing). This
|
|
83
|
+
* function detects and handles this pattern:
|
|
84
|
+
*
|
|
85
|
+
* 1. Detects auto-caption format (presence of <c> word-timing tags)
|
|
86
|
+
* 2. For auto-captions: extracts only the NEW text from each cue's second
|
|
87
|
+
* line, skips transition cues, and concatenates into clean sentences
|
|
88
|
+
* 3. For manual subtitles: passes through cleanly with no data loss
|
|
89
|
+
* 4. Outputs: start_time, end_time, text
|
|
37
90
|
*/
|
|
38
91
|
function vttToCsv(vtt) {
|
|
39
92
|
const lines = vtt.split("\n");
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
let
|
|
93
|
+
const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
|
|
94
|
+
const rawCues = [];
|
|
95
|
+
let currentTs = null;
|
|
96
|
+
let currentTextLines = [];
|
|
43
97
|
for (const line of lines) {
|
|
44
98
|
const trimmed = line.trim();
|
|
45
|
-
// Timestamp line: 00:00:01.000 --> 00:00:04.000
|
|
46
99
|
if (trimmed.includes(" --> ")) {
|
|
47
100
|
// Flush previous cue
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
if (currentTs && currentTextLines.length > 0) {
|
|
102
|
+
let text;
|
|
103
|
+
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
104
|
+
// Auto-caption: line 1 = repeated text, line 2 = new text with <c> tags
|
|
105
|
+
// Only take line 2 (new content)
|
|
106
|
+
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
107
|
+
.replace(/<[^>]*>/g, "")
|
|
108
|
+
.trim());
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Manual subtitle or single-line cue: take all lines
|
|
112
|
+
text = decodeHtmlEntities(currentTextLines
|
|
113
|
+
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join(" "));
|
|
116
|
+
}
|
|
117
|
+
if (text) {
|
|
118
|
+
rawCues.push({ ...currentTs, text });
|
|
119
|
+
}
|
|
51
120
|
}
|
|
52
|
-
|
|
53
|
-
|
|
121
|
+
currentTs = parseTimestamp(trimmed);
|
|
122
|
+
currentTextLines = [];
|
|
123
|
+
}
|
|
124
|
+
else if (trimmed &&
|
|
125
|
+
!trimmed.startsWith("WEBVTT") &&
|
|
126
|
+
!trimmed.startsWith("Kind:") &&
|
|
127
|
+
!trimmed.startsWith("Language:") &&
|
|
128
|
+
!/^\d+$/.test(trimmed)) {
|
|
129
|
+
currentTextLines.push(trimmed);
|
|
54
130
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
131
|
+
}
|
|
132
|
+
// Flush last
|
|
133
|
+
if (currentTs && currentTextLines.length > 0) {
|
|
134
|
+
let text;
|
|
135
|
+
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
136
|
+
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
137
|
+
.replace(/<[^>]*>/g, "")
|
|
138
|
+
.trim());
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
text = decodeHtmlEntities(currentTextLines
|
|
142
|
+
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" "));
|
|
145
|
+
}
|
|
146
|
+
if (text) {
|
|
147
|
+
rawCues.push({ ...currentTs, text });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (rawCues.length === 0) {
|
|
151
|
+
return "start_time,end_time,text\n";
|
|
152
|
+
}
|
|
153
|
+
// ── Step 2: Deduplicate ───────────────────────────────────────
|
|
154
|
+
const deduped = [];
|
|
155
|
+
for (let i = 0; i < rawCues.length; i++) {
|
|
156
|
+
const cur = rawCues[i];
|
|
157
|
+
// Skip tiny transition cues (duration < 50ms)
|
|
158
|
+
const duration = cur.endSec - cur.startSec;
|
|
159
|
+
if (duration < 0.05)
|
|
160
|
+
continue;
|
|
161
|
+
// Merge with previous if same text
|
|
162
|
+
if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
|
|
163
|
+
deduped[deduped.length - 1].endSec = cur.endSec;
|
|
164
|
+
deduped[deduped.length - 1].endStr = cur.endStr;
|
|
165
|
+
continue;
|
|
60
166
|
}
|
|
167
|
+
deduped.push({ ...cur });
|
|
61
168
|
}
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
169
|
+
// ── Step 3: Build CSV ─────────────────────────────────────────
|
|
170
|
+
const csvRows = ["start_time,end_time,text"];
|
|
171
|
+
for (const cue of deduped) {
|
|
172
|
+
csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
|
|
66
173
|
}
|
|
67
|
-
return
|
|
174
|
+
return csvRows.join("\n") + "\n";
|
|
175
|
+
}
|
|
176
|
+
function todayDateStr() {
|
|
177
|
+
const d = new Date();
|
|
178
|
+
const yyyy = d.getFullYear();
|
|
179
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
180
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
181
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
68
182
|
}
|
|
69
183
|
async function downloadSubtitle(videoId, lang, format) {
|
|
70
184
|
mkdirSync(PATHS.subtitlesDir, { recursive: true });
|
|
71
|
-
const outTemplate = join(PATHS.subtitlesDir, `${videoId}_${lang}`);
|
|
185
|
+
const outTemplate = join(PATHS.subtitlesDir, `${todayDateStr()}_${videoId}_${lang}`);
|
|
72
186
|
// CSV is not a yt-dlp native format — download as VTT then convert
|
|
73
187
|
const dlFormat = format === "csv" ? "vtt" : format;
|
|
74
188
|
const result = await runYtDlp([
|
|
@@ -113,7 +227,7 @@ async function downloadSubtitle(videoId, lang, format) {
|
|
|
113
227
|
const dir = PATHS.subtitlesDir;
|
|
114
228
|
try {
|
|
115
229
|
const files = readdirSync(dir);
|
|
116
|
-
const prefix = `${videoId}_${lang}`;
|
|
230
|
+
const prefix = `${todayDateStr()}_${videoId}_${lang}`;
|
|
117
231
|
const match = files.find((f) => f.startsWith(prefix));
|
|
118
232
|
if (match) {
|
|
119
233
|
foundFile = join(dir, match);
|
|
@@ -171,6 +285,17 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
171
285
|
const videoId = resolveVideoInput(video);
|
|
172
286
|
if (!videoId)
|
|
173
287
|
return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
|
|
288
|
+
// Cookie pre-check: missing or expired → try headless refresh
|
|
289
|
+
if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
|
|
290
|
+
let refreshed = false;
|
|
291
|
+
try {
|
|
292
|
+
refreshed = await tryHeadlessRefresh();
|
|
293
|
+
}
|
|
294
|
+
catch { /* */ }
|
|
295
|
+
if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
|
|
296
|
+
return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
174
299
|
const usingDefaults = !languages;
|
|
175
300
|
const langs = languages ?? config.default_languages;
|
|
176
301
|
const fmt = format ?? "vtt";
|
|
@@ -182,7 +307,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
182
307
|
const lang = langs[i];
|
|
183
308
|
const dl = await downloadSubtitle(videoId, lang, fmt);
|
|
184
309
|
if (dl.cookiesExpired) {
|
|
185
|
-
|
|
310
|
+
// Try headless auto-refresh
|
|
311
|
+
let refreshed = false;
|
|
312
|
+
try {
|
|
313
|
+
refreshed = await tryHeadlessRefresh();
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Playwright not available or browser failed — fall through
|
|
317
|
+
}
|
|
318
|
+
if (refreshed) {
|
|
319
|
+
// Retry the download with fresh cookies
|
|
320
|
+
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
321
|
+
if (retry.ok) {
|
|
322
|
+
if (retry.text) {
|
|
323
|
+
results.push({ language: lang, status: "ok", text: retry.text, file_path: retry.filePath });
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
results.push({ language: lang, status: "ok", file_path: retry.filePath });
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Retry also failed with expired cookies — give up
|
|
331
|
+
}
|
|
332
|
+
return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
|
|
333
|
+
"Please run in your terminal: yt-mcp setup-cookies");
|
|
186
334
|
}
|
|
187
335
|
if (!dl.ok) {
|
|
188
336
|
// When using default languages, silently skip unavailable ones
|
|
@@ -235,9 +383,16 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
235
383
|
}
|
|
236
384
|
const langs = languages ?? config.default_languages;
|
|
237
385
|
const fmt = format ?? "vtt";
|
|
238
|
-
// Cookie pre-check
|
|
239
|
-
if (!hasSIDCookies(PATHS.cookiesTxt)) {
|
|
240
|
-
|
|
386
|
+
// Cookie pre-check: missing or expired → try headless refresh
|
|
387
|
+
if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
|
|
388
|
+
let refreshed = false;
|
|
389
|
+
try {
|
|
390
|
+
refreshed = await tryHeadlessRefresh();
|
|
391
|
+
}
|
|
392
|
+
catch { /* */ }
|
|
393
|
+
if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
|
|
394
|
+
return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
|
|
395
|
+
}
|
|
241
396
|
}
|
|
242
397
|
const results = [];
|
|
243
398
|
let succeeded = 0;
|
|
@@ -267,7 +422,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
267
422
|
}
|
|
268
423
|
}
|
|
269
424
|
if (cookiesExpired) {
|
|
270
|
-
|
|
425
|
+
// Try headless auto-refresh
|
|
426
|
+
let refreshed = false;
|
|
427
|
+
try {
|
|
428
|
+
refreshed = await tryHeadlessRefresh();
|
|
429
|
+
}
|
|
430
|
+
catch { /* fall through */ }
|
|
431
|
+
if (refreshed) {
|
|
432
|
+
// Retry this video's subtitles
|
|
433
|
+
let retrySuccess = false;
|
|
434
|
+
for (const lang of langs) {
|
|
435
|
+
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
436
|
+
if (retry.ok) {
|
|
437
|
+
langFound.push(lang);
|
|
438
|
+
retrySuccess = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (retrySuccess) {
|
|
442
|
+
cookiesExpired = false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (cookiesExpired) {
|
|
446
|
+
return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
|
|
447
|
+
"Please run in your terminal: yt-mcp setup-cookies");
|
|
448
|
+
}
|
|
271
449
|
}
|
|
272
450
|
if (langFound.length > 0) {
|
|
273
451
|
succeeded++;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless cookie auto-refresh.
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright to open YouTube in a headless browser with the existing
|
|
5
|
+
* browser-profile. If Google session is still valid, YouTube cookies are
|
|
6
|
+
* extracted and saved automatically — no user interaction needed.
|
|
7
|
+
*
|
|
8
|
+
* If Google session has expired too, returns false so the caller can
|
|
9
|
+
* prompt the user for interactive login.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Attempt to refresh YouTube cookies headlessly using existing browser profile.
|
|
13
|
+
*
|
|
14
|
+
* @returns true if cookies were refreshed, false if Google session expired
|
|
15
|
+
* (needs interactive login).
|
|
16
|
+
* @throws if Playwright is not installed or browser can't launch.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tryHeadlessRefresh(): Promise<boolean>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless cookie auto-refresh.
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright to open YouTube in a headless browser with the existing
|
|
5
|
+
* browser-profile. If Google session is still valid, YouTube cookies are
|
|
6
|
+
* extracted and saved automatically — no user interaction needed.
|
|
7
|
+
*
|
|
8
|
+
* If Google session has expired too, returns false so the caller can
|
|
9
|
+
* prompt the user for interactive login.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { PATHS, ensureConfigDir } from "./config.js";
|
|
13
|
+
import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
|
|
14
|
+
const HEADLESS_TIMEOUT_MS = 30_000;
|
|
15
|
+
/**
|
|
16
|
+
* Attempt to refresh YouTube cookies headlessly using existing browser profile.
|
|
17
|
+
*
|
|
18
|
+
* @returns true if cookies were refreshed, false if Google session expired
|
|
19
|
+
* (needs interactive login).
|
|
20
|
+
* @throws if Playwright is not installed or browser can't launch.
|
|
21
|
+
*/
|
|
22
|
+
export async function tryHeadlessRefresh() {
|
|
23
|
+
// No browser profile = never logged in = can't refresh
|
|
24
|
+
if (!existsSync(PATHS.browserProfile)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
ensureConfigDir();
|
|
28
|
+
// Dynamic import to keep serve startup fast
|
|
29
|
+
let chromium;
|
|
30
|
+
try {
|
|
31
|
+
const pw = await import("playwright");
|
|
32
|
+
chromium = pw.chromium;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false; // Playwright not installed — can't refresh
|
|
36
|
+
}
|
|
37
|
+
const channel = await detectBrowserChannel(chromium);
|
|
38
|
+
let context;
|
|
39
|
+
try {
|
|
40
|
+
context = await chromium.launchPersistentContext(PATHS.browserProfile, {
|
|
41
|
+
headless: true,
|
|
42
|
+
channel,
|
|
43
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false; // Browser launch failed
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
51
|
+
await page.goto("https://www.youtube.com", { timeout: HEADLESS_TIMEOUT_MS });
|
|
52
|
+
await page.waitForLoadState("domcontentloaded");
|
|
53
|
+
const cookies = await context.cookies("https://www.youtube.com");
|
|
54
|
+
if (!hasYouTubeSession(cookies)) {
|
|
55
|
+
// Google session expired — can't auto-refresh
|
|
56
|
+
await context.close().catch(() => { });
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Wait a moment for all cookies to settle
|
|
60
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
61
|
+
const finalCookies = await context.cookies("https://www.youtube.com");
|
|
62
|
+
saveCookiesToDisk(finalCookies);
|
|
63
|
+
await context.close().catch(() => { });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
await context.close().catch(() => { });
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/utils/cookies.d.ts
CHANGED
|
@@ -9,3 +9,10 @@ export interface CookieEntry {
|
|
|
9
9
|
}
|
|
10
10
|
export declare function cookiesToNetscape(cookies: CookieEntry[]): string;
|
|
11
11
|
export declare function hasSIDCookies(cookiesPath: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Check if YouTube session cookies have expired by parsing Netscape cookie
|
|
14
|
+
* file timestamps (field 5, Unix epoch seconds).
|
|
15
|
+
*
|
|
16
|
+
* Returns true if: file missing, no session cookies, or ALL session cookies expired.
|
|
17
|
+
*/
|
|
18
|
+
export declare function areCookiesExpired(cookiesPath: string): boolean;
|
package/dist/utils/cookies.js
CHANGED
|
@@ -26,3 +26,53 @@ export function hasSIDCookies(cookiesPath) {
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Cookie names that indicate a valid YouTube session.
|
|
31
|
+
* If at least one of these is present and not expired, cookies are valid.
|
|
32
|
+
*/
|
|
33
|
+
const SESSION_COOKIE_PATTERNS = [
|
|
34
|
+
"SID", "HSID", "SSID", "APISID", "SAPISID",
|
|
35
|
+
"__Secure-1PSID", "__Secure-3PSID",
|
|
36
|
+
"__Secure-1PSIDTS", "__Secure-3PSIDTS",
|
|
37
|
+
"LOGIN_INFO",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Check if YouTube session cookies have expired by parsing Netscape cookie
|
|
41
|
+
* file timestamps (field 5, Unix epoch seconds).
|
|
42
|
+
*
|
|
43
|
+
* Returns true if: file missing, no session cookies, or ALL session cookies expired.
|
|
44
|
+
*/
|
|
45
|
+
export function areCookiesExpired(cookiesPath) {
|
|
46
|
+
if (!existsSync(cookiesPath))
|
|
47
|
+
return true;
|
|
48
|
+
try {
|
|
49
|
+
const content = readFileSync(cookiesPath, "utf8");
|
|
50
|
+
const nowSec = Date.now() / 1000;
|
|
51
|
+
let foundAnySession = false;
|
|
52
|
+
let foundAnyValid = false;
|
|
53
|
+
for (const line of content.split("\n")) {
|
|
54
|
+
if (line.startsWith("#") || !line.trim())
|
|
55
|
+
continue;
|
|
56
|
+
const fields = line.split("\t");
|
|
57
|
+
if (fields.length < 7)
|
|
58
|
+
continue;
|
|
59
|
+
const name = fields[5];
|
|
60
|
+
if (!SESSION_COOKIE_PATTERNS.includes(name))
|
|
61
|
+
continue;
|
|
62
|
+
foundAnySession = true;
|
|
63
|
+
const expiry = Number(fields[4]);
|
|
64
|
+
// expiry 0 = session cookie (no expiration) — treat as valid
|
|
65
|
+
if (expiry === 0 || expiry >= nowSec) {
|
|
66
|
+
foundAnyValid = true;
|
|
67
|
+
break; // At least one valid session cookie — not expired
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// No session cookies at all = need login; all expired = need refresh
|
|
71
|
+
if (!foundAnySession)
|
|
72
|
+
return true;
|
|
73
|
+
return !foundAnyValid;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mkterswingman/yt-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "YouTube MCP client — local subtitles + remote API proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
20
|
+
"playwright": "^1.58.0",
|
|
20
21
|
"zod": "^4.3.6"
|
|
21
22
|
},
|
|
22
23
|
"optionalDependencies": {
|
|
23
|
-
"playwright": "^1.58.0",
|
|
24
24
|
"puppeteer-core": "^24.0.0"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
@@ -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(
|