@mkterswingman/5mghost-yonder 0.0.2 → 0.0.3
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/README.md +44 -3
- package/dist/auth/sharedAuth.d.ts +10 -0
- package/dist/auth/sharedAuth.js +24 -0
- package/dist/auth/tokenManager.d.ts +10 -1
- package/dist/auth/tokenManager.js +14 -22
- package/dist/cli/check.js +6 -3
- package/dist/cli/index.d.ts +15 -1
- package/dist/cli/index.js +74 -31
- package/dist/cli/runtime.d.ts +9 -0
- package/dist/cli/runtime.js +35 -0
- package/dist/cli/serve.js +3 -1
- package/dist/cli/setup.js +60 -61
- package/dist/cli/setupCookies.js +2 -2
- package/dist/cli/smoke.d.ts +27 -0
- package/dist/cli/smoke.js +108 -0
- package/dist/cli/uninstall.d.ts +1 -0
- package/dist/cli/uninstall.js +67 -0
- package/dist/download/downloader.d.ts +64 -0
- package/dist/download/downloader.js +264 -0
- package/dist/download/jobManager.d.ts +21 -0
- package/dist/download/jobManager.js +198 -0
- package/dist/download/types.d.ts +43 -0
- package/dist/download/types.js +1 -0
- package/dist/runtime/ffmpegRuntime.d.ts +13 -0
- package/dist/runtime/ffmpegRuntime.js +51 -0
- package/dist/runtime/installers.d.ts +12 -0
- package/dist/runtime/installers.js +45 -0
- package/dist/runtime/manifest.d.ts +18 -0
- package/dist/runtime/manifest.js +43 -0
- package/dist/runtime/playwrightRuntime.d.ts +13 -0
- package/dist/runtime/playwrightRuntime.js +37 -0
- package/dist/runtime/systemDeps.d.ts +3 -0
- package/dist/runtime/systemDeps.js +30 -0
- package/dist/runtime/ytdlpRuntime.d.ts +14 -0
- package/dist/runtime/ytdlpRuntime.js +58 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +4 -1
- package/dist/tools/downloads.d.ts +11 -0
- package/dist/tools/downloads.js +220 -0
- package/dist/tools/subtitles.d.ts +25 -0
- package/dist/tools/subtitles.js +135 -47
- package/dist/utils/config.d.ts +28 -0
- package/dist/utils/config.js +40 -11
- package/dist/utils/ffmpeg.d.ts +5 -0
- package/dist/utils/ffmpeg.js +16 -0
- package/dist/utils/ffmpegPath.d.ts +8 -0
- package/dist/utils/ffmpegPath.js +21 -0
- package/dist/utils/formatters.d.ts +4 -0
- package/dist/utils/formatters.js +42 -0
- package/dist/utils/mediaPaths.d.ts +7 -0
- package/dist/utils/mediaPaths.js +10 -0
- package/dist/utils/openClaw.d.ts +17 -0
- package/dist/utils/openClaw.js +79 -0
- package/dist/utils/videoInput.js +3 -0
- package/dist/utils/videoMetadata.d.ts +11 -0
- package/dist/utils/videoMetadata.js +1 -0
- package/dist/utils/ytdlp.d.ts +17 -1
- package/dist/utils/ytdlp.js +89 -2
- package/dist/utils/ytdlpPath.d.ts +9 -2
- package/dist/utils/ytdlpPath.js +19 -20
- package/dist/utils/ytdlpProgress.d.ts +13 -0
- package/dist/utils/ytdlpProgress.js +77 -0
- package/package.json +5 -3
- package/scripts/download-ytdlp.mjs +1 -1
- package/scripts/install.ps1 +9 -0
- package/scripts/install.sh +15 -0
package/dist/tools/subtitles.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, 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";
|
|
@@ -7,6 +7,10 @@ import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
|
|
|
7
7
|
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
8
8
|
import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
|
|
9
9
|
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
10
|
+
const COOKIE_SETUP_COMMAND = "yt-mcp setup-cookies";
|
|
11
|
+
const COOKIE_MISSING_MESSAGE = `No valid YouTube cookies found.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
12
|
+
const COOKIE_EXPIRED_MESSAGE = `YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
13
|
+
const COOKIE_JOB_MESSAGE = `YouTube cookies are missing or expired.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
10
14
|
function toolOk(payload) {
|
|
11
15
|
return {
|
|
12
16
|
structuredContent: payload,
|
|
@@ -32,6 +36,44 @@ function sleep(ms) {
|
|
|
32
36
|
function randomSleep(min, max) {
|
|
33
37
|
return sleep(Math.random() * (max - min) + min);
|
|
34
38
|
}
|
|
39
|
+
async function tryRefreshSubtitleCookies() {
|
|
40
|
+
try {
|
|
41
|
+
return await tryHeadlessRefresh();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isCookieFailureText(error) {
|
|
48
|
+
if (!error) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const normalized = error.toLowerCase();
|
|
52
|
+
return (normalized.includes("cookies_expired") ||
|
|
53
|
+
normalized.includes("cookie") ||
|
|
54
|
+
normalized.includes("sign in") ||
|
|
55
|
+
normalized.includes("login") ||
|
|
56
|
+
normalized.includes("not a bot"));
|
|
57
|
+
}
|
|
58
|
+
export function toReadableSubtitleJobError(error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
return isCookieFailureText(message) ? COOKIE_JOB_MESSAGE : message;
|
|
61
|
+
}
|
|
62
|
+
async function ensureSubtitleCookiesReady() {
|
|
63
|
+
if (hasSIDCookies(PATHS.cookiesTxt) && !areCookiesExpired(PATHS.cookiesTxt)) {
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
67
|
+
if (refreshed && hasSIDCookies(PATHS.cookiesTxt) && !areCookiesExpired(PATHS.cookiesTxt)) {
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: "COOKIES_MISSING",
|
|
73
|
+
toolMessage: COOKIE_MISSING_MESSAGE,
|
|
74
|
+
jobMessage: COOKIE_JOB_MESSAGE,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
35
77
|
/**
|
|
36
78
|
* Decode common HTML entities found in YouTube auto-captions.
|
|
37
79
|
*/
|
|
@@ -88,7 +130,7 @@ function csvEscapeField(value) {
|
|
|
88
130
|
* 3. For manual subtitles: passes through cleanly with no data loss
|
|
89
131
|
* 4. Outputs: start_time, end_time, text
|
|
90
132
|
*/
|
|
91
|
-
function vttToCsv(vtt) {
|
|
133
|
+
export function vttToCsv(vtt) {
|
|
92
134
|
const lines = vtt.split("\n");
|
|
93
135
|
const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
|
|
94
136
|
const rawCues = [];
|
|
@@ -180,9 +222,13 @@ function todayDateStr() {
|
|
|
180
222
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
181
223
|
return `${yyyy}-${mm}-${dd}`;
|
|
182
224
|
}
|
|
183
|
-
async function downloadSubtitle(videoId, lang, format) {
|
|
184
|
-
|
|
185
|
-
|
|
225
|
+
async function downloadSubtitle(videoId, lang, format, options = {}) {
|
|
226
|
+
const outputDir = options.outputDir ?? PATHS.subtitlesDir;
|
|
227
|
+
mkdirSync(outputDir, { recursive: true });
|
|
228
|
+
if (options.targetFile) {
|
|
229
|
+
mkdirSync(dirname(options.targetFile), { recursive: true });
|
|
230
|
+
}
|
|
231
|
+
const outTemplate = join(outputDir, options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`);
|
|
186
232
|
// CSV is not a yt-dlp native format — download as VTT then convert
|
|
187
233
|
const dlFormat = format === "csv" ? "vtt" : format;
|
|
188
234
|
const result = await runYtDlp([
|
|
@@ -196,7 +242,7 @@ async function downloadSubtitle(videoId, lang, format) {
|
|
|
196
242
|
dlFormat,
|
|
197
243
|
"--output",
|
|
198
244
|
outTemplate,
|
|
199
|
-
`https://www.youtube.com/watch?v=${videoId}`,
|
|
245
|
+
options.sourceUrl ?? `https://www.youtube.com/watch?v=${videoId}`,
|
|
200
246
|
]);
|
|
201
247
|
if (result.exitCode !== 0 &&
|
|
202
248
|
(result.stderr.includes("Sign in") ||
|
|
@@ -224,10 +270,10 @@ async function downloadSubtitle(videoId, lang, format) {
|
|
|
224
270
|
}
|
|
225
271
|
if (!foundFile) {
|
|
226
272
|
// Try auto-generated subtitles which may have slightly different naming
|
|
227
|
-
const dir =
|
|
273
|
+
const dir = outputDir;
|
|
228
274
|
try {
|
|
229
275
|
const files = readdirSync(dir);
|
|
230
|
-
const prefix = `${todayDateStr()}_${videoId}_${lang}`;
|
|
276
|
+
const prefix = options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`;
|
|
231
277
|
const match = files.find((f) => f.startsWith(prefix));
|
|
232
278
|
if (match) {
|
|
233
279
|
foundFile = join(dir, match);
|
|
@@ -245,11 +291,24 @@ async function downloadSubtitle(videoId, lang, format) {
|
|
|
245
291
|
}
|
|
246
292
|
// If CSV format requested, convert VTT to CSV (timestamp, text)
|
|
247
293
|
if (format === "csv") {
|
|
294
|
+
const vttPath = foundFile;
|
|
248
295
|
const vttContent = readFileSync(foundFile, "utf8");
|
|
249
296
|
const csvContent = vttToCsv(vttContent);
|
|
250
297
|
const csvPath = foundFile.replace(/\.vtt$/, ".csv");
|
|
251
298
|
writeFileSync(csvPath, csvContent, "utf8");
|
|
252
299
|
foundFile = csvPath;
|
|
300
|
+
if (vttPath !== csvPath) {
|
|
301
|
+
try {
|
|
302
|
+
unlinkSync(vttPath);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// ignore
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (options.targetFile && foundFile !== options.targetFile) {
|
|
310
|
+
renameSync(foundFile, options.targetFile);
|
|
311
|
+
foundFile = options.targetFile;
|
|
253
312
|
}
|
|
254
313
|
const stat = statSync(foundFile);
|
|
255
314
|
if (stat.size <= 100 * 1024) {
|
|
@@ -258,6 +317,63 @@ async function downloadSubtitle(videoId, lang, format) {
|
|
|
258
317
|
}
|
|
259
318
|
return { ok: true, filePath: foundFile };
|
|
260
319
|
}
|
|
320
|
+
export async function downloadSubtitlesForLanguages(input) {
|
|
321
|
+
const cookieCheck = await ensureSubtitleCookiesReady();
|
|
322
|
+
if (!cookieCheck.ok) {
|
|
323
|
+
throw new Error(cookieCheck.jobMessage);
|
|
324
|
+
}
|
|
325
|
+
const filesByFormat = {};
|
|
326
|
+
const total = input.formats.length * input.languages.length;
|
|
327
|
+
let completed = 0;
|
|
328
|
+
for (const format of input.formats) {
|
|
329
|
+
filesByFormat[format] = [];
|
|
330
|
+
for (const lang of input.languages) {
|
|
331
|
+
const targetFile = join(input.subtitlesDir, `${lang}.${format}`);
|
|
332
|
+
let result = await downloadSubtitle(input.videoId, lang, format, {
|
|
333
|
+
sourceUrl: input.sourceUrl,
|
|
334
|
+
outputDir: input.subtitlesDir,
|
|
335
|
+
outputStem: `${lang}`,
|
|
336
|
+
targetFile,
|
|
337
|
+
});
|
|
338
|
+
if (result.cookiesExpired) {
|
|
339
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
340
|
+
if (refreshed) {
|
|
341
|
+
result = await downloadSubtitle(input.videoId, lang, format, {
|
|
342
|
+
sourceUrl: input.sourceUrl,
|
|
343
|
+
outputDir: input.subtitlesDir,
|
|
344
|
+
outputStem: `${lang}`,
|
|
345
|
+
targetFile,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (!result.ok || !result.filePath) {
|
|
350
|
+
if (isCookieFailureText(result.error)) {
|
|
351
|
+
throw new Error(COOKIE_JOB_MESSAGE);
|
|
352
|
+
}
|
|
353
|
+
if (input.skipMissingLanguages && isUnavailableSubtitleError(result.error)) {
|
|
354
|
+
completed += 1;
|
|
355
|
+
input.onProgress?.(completed, total);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
throw new Error(result.error ?? `Failed to download ${format} subtitle for ${lang}`);
|
|
359
|
+
}
|
|
360
|
+
filesByFormat[format].push(result.filePath);
|
|
361
|
+
completed += 1;
|
|
362
|
+
input.onProgress?.(completed, total);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return filesByFormat;
|
|
366
|
+
}
|
|
367
|
+
function isUnavailableSubtitleError(error) {
|
|
368
|
+
if (!error) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const normalized = error.toLowerCase();
|
|
372
|
+
return (normalized.includes("no subtitle file found") ||
|
|
373
|
+
normalized.includes("no subtitles") ||
|
|
374
|
+
normalized.includes("subtitle is not available") ||
|
|
375
|
+
normalized.includes("requested subtitles are not available"));
|
|
376
|
+
}
|
|
261
377
|
export function registerSubtitleTools(server, config, tokenManager) {
|
|
262
378
|
/**
|
|
263
379
|
* Auth guard — every tool call must pass authentication.
|
|
@@ -285,16 +401,9 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
285
401
|
const videoId = resolveVideoInput(video);
|
|
286
402
|
if (!videoId)
|
|
287
403
|
return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
|
|
288
|
-
|
|
289
|
-
if (!
|
|
290
|
-
|
|
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
|
-
}
|
|
404
|
+
const cookieCheck = await ensureSubtitleCookiesReady();
|
|
405
|
+
if (!cookieCheck.ok) {
|
|
406
|
+
return toolErr(cookieCheck.code, cookieCheck.toolMessage);
|
|
298
407
|
}
|
|
299
408
|
const usingDefaults = !languages;
|
|
300
409
|
const langs = languages ?? config.default_languages;
|
|
@@ -307,14 +416,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
307
416
|
const lang = langs[i];
|
|
308
417
|
const dl = await downloadSubtitle(videoId, lang, fmt);
|
|
309
418
|
if (dl.cookiesExpired) {
|
|
310
|
-
|
|
311
|
-
let refreshed = false;
|
|
312
|
-
try {
|
|
313
|
-
refreshed = await tryHeadlessRefresh();
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// Playwright not available or browser failed — fall through
|
|
317
|
-
}
|
|
419
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
318
420
|
if (refreshed) {
|
|
319
421
|
// Retry the download with fresh cookies
|
|
320
422
|
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
@@ -329,8 +431,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
329
431
|
}
|
|
330
432
|
// Retry also failed with expired cookies — give up
|
|
331
433
|
}
|
|
332
|
-
return toolErr("COOKIES_EXPIRED",
|
|
333
|
-
"Please run in your terminal: yt-mcp setup-cookies");
|
|
434
|
+
return toolErr("COOKIES_EXPIRED", COOKIE_EXPIRED_MESSAGE);
|
|
334
435
|
}
|
|
335
436
|
if (!dl.ok) {
|
|
336
437
|
// When using default languages, silently skip unavailable ones
|
|
@@ -383,16 +484,9 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
383
484
|
}
|
|
384
485
|
const langs = languages ?? config.default_languages;
|
|
385
486
|
const fmt = format ?? "vtt";
|
|
386
|
-
|
|
387
|
-
if (!
|
|
388
|
-
|
|
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
|
-
}
|
|
487
|
+
const cookieCheck = await ensureSubtitleCookiesReady();
|
|
488
|
+
if (!cookieCheck.ok) {
|
|
489
|
+
return toolErr(cookieCheck.code, cookieCheck.toolMessage);
|
|
396
490
|
}
|
|
397
491
|
const results = [];
|
|
398
492
|
let succeeded = 0;
|
|
@@ -422,12 +516,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
422
516
|
}
|
|
423
517
|
}
|
|
424
518
|
if (cookiesExpired) {
|
|
425
|
-
|
|
426
|
-
let refreshed = false;
|
|
427
|
-
try {
|
|
428
|
-
refreshed = await tryHeadlessRefresh();
|
|
429
|
-
}
|
|
430
|
-
catch { /* fall through */ }
|
|
519
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
431
520
|
if (refreshed) {
|
|
432
521
|
// Retry this video's subtitles
|
|
433
522
|
let retrySuccess = false;
|
|
@@ -443,8 +532,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
443
532
|
}
|
|
444
533
|
}
|
|
445
534
|
if (cookiesExpired) {
|
|
446
|
-
return toolErr("COOKIES_EXPIRED",
|
|
447
|
-
"Please run in your terminal: yt-mcp setup-cookies");
|
|
535
|
+
return toolErr("COOKIES_EXPIRED", COOKIE_EXPIRED_MESSAGE);
|
|
448
536
|
}
|
|
449
537
|
}
|
|
450
538
|
if (langFound.length > 0) {
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
export declare function getConfigDir(homeDir?: string): string;
|
|
2
|
+
export declare function getSharedAuthDir(homeDir?: string): string;
|
|
3
|
+
export declare const MEDIA_ROOT_DIR: string;
|
|
4
|
+
export declare function buildRuntimeBinaryPath(runtimeBinDir: string, binaryName: string): string;
|
|
5
|
+
export declare function buildPaths(homeDir?: string): {
|
|
6
|
+
configDir: string;
|
|
7
|
+
authJson: string;
|
|
8
|
+
cookiesTxt: string;
|
|
9
|
+
browserProfile: string;
|
|
10
|
+
launcherJs: string;
|
|
11
|
+
npmCacheDir: string;
|
|
12
|
+
subtitlesDir: string;
|
|
13
|
+
configJson: string;
|
|
14
|
+
runtimeRootDir: string;
|
|
15
|
+
runtimeBinDir: string;
|
|
16
|
+
runtimeManifestJson: string;
|
|
17
|
+
sharedAuthDir: string;
|
|
18
|
+
sharedAuthJson: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function buildSharedPaths(homeDir?: string): {
|
|
21
|
+
sharedAuthDir: string;
|
|
22
|
+
sharedAuthJson: string;
|
|
23
|
+
};
|
|
1
24
|
export declare const PATHS: {
|
|
2
25
|
configDir: string;
|
|
3
26
|
authJson: string;
|
|
@@ -7,6 +30,11 @@ export declare const PATHS: {
|
|
|
7
30
|
npmCacheDir: string;
|
|
8
31
|
subtitlesDir: string;
|
|
9
32
|
configJson: string;
|
|
33
|
+
runtimeRootDir: string;
|
|
34
|
+
runtimeBinDir: string;
|
|
35
|
+
runtimeManifestJson: string;
|
|
36
|
+
sharedAuthDir: string;
|
|
37
|
+
sharedAuthJson: string;
|
|
10
38
|
};
|
|
11
39
|
export interface YtMcpConfig {
|
|
12
40
|
/** Auth gateway root URL — for OAuth, PAT, introspection (e.g., https://mkterswingman.com) */
|
package/dist/utils/config.js
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
export function getConfigDir(homeDir = homedir()) {
|
|
5
|
+
return join(homeDir, ".yt-mcp");
|
|
6
|
+
}
|
|
7
|
+
export function getSharedAuthDir(homeDir = homedir()) {
|
|
8
|
+
return join(homeDir, ".mkterswingman");
|
|
9
|
+
}
|
|
10
|
+
export const MEDIA_ROOT_DIR = join(homedir(), "Downloads", "yt-mcp");
|
|
11
|
+
export function buildRuntimeBinaryPath(runtimeBinDir, binaryName) {
|
|
12
|
+
return join(runtimeBinDir, binaryName);
|
|
13
|
+
}
|
|
14
|
+
export function buildPaths(homeDir = homedir()) {
|
|
15
|
+
const configDir = getConfigDir(homeDir);
|
|
16
|
+
const runtimeRootDir = join(configDir, "runtime");
|
|
17
|
+
const runtimeBinDir = join(runtimeRootDir, "bin");
|
|
18
|
+
const sharedAuthDir = getSharedAuthDir(homeDir);
|
|
19
|
+
return {
|
|
20
|
+
configDir,
|
|
21
|
+
authJson: join(configDir, "auth.json"),
|
|
22
|
+
cookiesTxt: join(configDir, "cookies.txt"),
|
|
23
|
+
browserProfile: join(configDir, "browser-profile"),
|
|
24
|
+
launcherJs: join(configDir, "launcher.mjs"),
|
|
25
|
+
npmCacheDir: join(configDir, "npm-cache"),
|
|
26
|
+
subtitlesDir: MEDIA_ROOT_DIR,
|
|
27
|
+
configJson: join(configDir, "config.json"),
|
|
28
|
+
runtimeRootDir,
|
|
29
|
+
runtimeBinDir,
|
|
30
|
+
runtimeManifestJson: join(runtimeRootDir, "runtime.json"),
|
|
31
|
+
sharedAuthDir,
|
|
32
|
+
sharedAuthJson: join(sharedAuthDir, "auth.json"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function buildSharedPaths(homeDir = homedir()) {
|
|
36
|
+
const sharedAuthDir = getSharedAuthDir(homeDir);
|
|
37
|
+
return {
|
|
38
|
+
sharedAuthDir,
|
|
39
|
+
sharedAuthJson: join(sharedAuthDir, "auth.json"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
6
42
|
export const PATHS = {
|
|
7
|
-
|
|
8
|
-
authJson: join(CONFIG_DIR, "auth.json"),
|
|
9
|
-
cookiesTxt: join(CONFIG_DIR, "cookies.txt"),
|
|
10
|
-
browserProfile: join(CONFIG_DIR, "browser-profile"),
|
|
11
|
-
launcherJs: join(CONFIG_DIR, "launcher.mjs"),
|
|
12
|
-
npmCacheDir: join(CONFIG_DIR, "npm-cache"),
|
|
13
|
-
subtitlesDir: SUBTITLES_DIR,
|
|
14
|
-
configJson: join(CONFIG_DIR, "config.json"),
|
|
43
|
+
...buildPaths(),
|
|
15
44
|
};
|
|
16
45
|
const DEFAULTS = {
|
|
17
46
|
auth_url: "https://mkterswingman.com",
|
|
@@ -23,7 +52,7 @@ const DEFAULTS = {
|
|
|
23
52
|
batch_max_size: 10,
|
|
24
53
|
};
|
|
25
54
|
export function ensureConfigDir() {
|
|
26
|
-
mkdirSync(
|
|
55
|
+
mkdirSync(PATHS.configDir, { recursive: true });
|
|
27
56
|
}
|
|
28
57
|
export function loadConfig() {
|
|
29
58
|
ensureConfigDir();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export function hasFfmpeg(runSpawnSync = spawnSync) {
|
|
3
|
+
return hasBinary("ffmpeg", runSpawnSync);
|
|
4
|
+
}
|
|
5
|
+
function hasBinary(binary, runSpawnSync) {
|
|
6
|
+
const result = runSpawnSync(binary, ["-version"], {
|
|
7
|
+
stdio: "ignore",
|
|
8
|
+
timeout: 30_000,
|
|
9
|
+
});
|
|
10
|
+
// Why: runtime probing should fail closed when the binary is absent,
|
|
11
|
+
// exits non-zero, or times out instead of hanging the caller.
|
|
12
|
+
return result.status === 0 && result.error == null;
|
|
13
|
+
}
|
|
14
|
+
export function isRuntimeMissingMessage(binary) {
|
|
15
|
+
return `${binary} is required for media download.`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ResolveFfmpegPathOptions {
|
|
2
|
+
envPath?: string | null;
|
|
3
|
+
runtimePath?: string;
|
|
4
|
+
runtimeExists?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function getRuntimeFfmpegPath(runtimeBinDir?: string): string;
|
|
7
|
+
export declare function resolveFfmpegPath(options?: ResolveFfmpegPathOptions): string;
|
|
8
|
+
export declare function getFfmpegPath(): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { PATHS, buildRuntimeBinaryPath } from "./config.js";
|
|
3
|
+
export function getRuntimeFfmpegPath(runtimeBinDir = PATHS.runtimeBinDir) {
|
|
4
|
+
const name = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
|
5
|
+
return buildRuntimeBinaryPath(runtimeBinDir, name);
|
|
6
|
+
}
|
|
7
|
+
export function resolveFfmpegPath(options = {}) {
|
|
8
|
+
const envPath = options.envPath ?? process.env.FFMPEG_PATH ?? null;
|
|
9
|
+
if (envPath) {
|
|
10
|
+
return envPath;
|
|
11
|
+
}
|
|
12
|
+
const runtimePath = options.runtimePath ?? getRuntimeFfmpegPath();
|
|
13
|
+
const runtimeExists = options.runtimeExists ?? existsSync(runtimePath);
|
|
14
|
+
if (runtimeExists) {
|
|
15
|
+
return runtimePath;
|
|
16
|
+
}
|
|
17
|
+
return "ffmpeg";
|
|
18
|
+
}
|
|
19
|
+
export function getFfmpegPath() {
|
|
20
|
+
return resolveFfmpegPath();
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
2
|
+
function formatScaled(value) {
|
|
3
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
4
|
+
return "0 B";
|
|
5
|
+
}
|
|
6
|
+
let scaled = value;
|
|
7
|
+
let unitIndex = 0;
|
|
8
|
+
while (scaled >= 1024 && unitIndex < UNITS.length - 1) {
|
|
9
|
+
scaled /= 1024;
|
|
10
|
+
unitIndex++;
|
|
11
|
+
}
|
|
12
|
+
const floored = Math.floor(scaled * 10) / 10;
|
|
13
|
+
return `${Number.isInteger(floored) ? floored.toFixed(0) : floored} ${UNITS[unitIndex]}`;
|
|
14
|
+
}
|
|
15
|
+
export function formatPercent(value) {
|
|
16
|
+
return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
|
|
17
|
+
}
|
|
18
|
+
export function formatBytes(bytes) {
|
|
19
|
+
return formatScaled(bytes);
|
|
20
|
+
}
|
|
21
|
+
export function formatSpeed(bytesPerSecond) {
|
|
22
|
+
return `${formatBytes(bytesPerSecond)}/s`;
|
|
23
|
+
}
|
|
24
|
+
export function formatEta(seconds) {
|
|
25
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
26
|
+
return "0s";
|
|
27
|
+
}
|
|
28
|
+
let remaining = Math.floor(seconds);
|
|
29
|
+
const hours = Math.floor(remaining / 3600);
|
|
30
|
+
remaining -= hours * 3600;
|
|
31
|
+
const minutes = Math.floor(remaining / 60);
|
|
32
|
+
remaining -= minutes * 60;
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (hours > 0) {
|
|
35
|
+
parts.push(`${hours}h`);
|
|
36
|
+
}
|
|
37
|
+
if (minutes > 0 || hours > 0) {
|
|
38
|
+
parts.push(`${minutes}m`);
|
|
39
|
+
}
|
|
40
|
+
parts.push(`${remaining}s`);
|
|
41
|
+
return parts.join(" ");
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export function buildMediaOutputPaths(rootDir, dateStr, videoId) {
|
|
3
|
+
const outputDir = join(rootDir, `${dateStr}_${videoId}`);
|
|
4
|
+
return {
|
|
5
|
+
outputDir,
|
|
6
|
+
videoFile: join(outputDir, "video.mp4"),
|
|
7
|
+
metadataFile: join(outputDir, "metadata.json"),
|
|
8
|
+
subtitlesDir: join(outputDir, "subtitles"),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LauncherCommand } from "./launcher.js";
|
|
2
|
+
export interface OpenClawServerConfig {
|
|
3
|
+
transport: "stdio";
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export declare function getOpenClawConfigPath(homeDir?: string): string;
|
|
9
|
+
export declare function buildOpenClawServerConfig(launcherCommand: LauncherCommand): OpenClawServerConfig;
|
|
10
|
+
export declare function upsertOpenClawConfigText(currentText: string | null, serverName: string, launcherCommand: LauncherCommand): string;
|
|
11
|
+
export declare function removeOpenClawConfigEntryText(currentText: string | null, serverName: string): {
|
|
12
|
+
changed: boolean;
|
|
13
|
+
nextText: string | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function isOpenClawInstallLikelyInstalled(detectBinary: (name: string) => boolean, configPath?: string): boolean;
|
|
16
|
+
export declare function writeOpenClawConfig(serverName: string, launcherCommand: LauncherCommand, configPath?: string): "created" | "updated";
|
|
17
|
+
export declare function removeOpenClawConfig(serverName: string, configPath?: string): "removed" | "missing";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function getOpenClawConfigPath(homeDir = homedir()) {
|
|
5
|
+
return join(homeDir, ".openclaw", "workspace", "config", "mcporter.json");
|
|
6
|
+
}
|
|
7
|
+
export function buildOpenClawServerConfig(launcherCommand) {
|
|
8
|
+
return {
|
|
9
|
+
transport: "stdio",
|
|
10
|
+
command: launcherCommand.command,
|
|
11
|
+
args: [...launcherCommand.args],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function parseOpenClawConfig(raw) {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (parsed && typeof parsed === "object") {
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
throw new Error("OpenClaw mcporter.json must be a JSON object");
|
|
20
|
+
}
|
|
21
|
+
export function upsertOpenClawConfigText(currentText, serverName, launcherCommand) {
|
|
22
|
+
const config = currentText ? parseOpenClawConfig(currentText) : {};
|
|
23
|
+
const servers = config.servers && typeof config.servers === "object" ? { ...config.servers } : {};
|
|
24
|
+
servers[serverName] = buildOpenClawServerConfig(launcherCommand);
|
|
25
|
+
const nextConfig = {
|
|
26
|
+
...config,
|
|
27
|
+
servers,
|
|
28
|
+
};
|
|
29
|
+
return JSON.stringify(nextConfig, null, 2);
|
|
30
|
+
}
|
|
31
|
+
export function removeOpenClawConfigEntryText(currentText, serverName) {
|
|
32
|
+
if (!currentText) {
|
|
33
|
+
return { changed: false, nextText: null };
|
|
34
|
+
}
|
|
35
|
+
const config = parseOpenClawConfig(currentText);
|
|
36
|
+
if (!config.servers || typeof config.servers !== "object" || !(serverName in config.servers)) {
|
|
37
|
+
return { changed: false, nextText: currentText };
|
|
38
|
+
}
|
|
39
|
+
const servers = { ...config.servers };
|
|
40
|
+
delete servers[serverName];
|
|
41
|
+
const nextConfig = { ...config };
|
|
42
|
+
if (Object.keys(servers).length === 0) {
|
|
43
|
+
delete nextConfig.servers;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
nextConfig.servers = servers;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
changed: true,
|
|
50
|
+
nextText: JSON.stringify(nextConfig, null, 2),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function isOpenClawInstallLikelyInstalled(detectBinary, configPath = getOpenClawConfigPath()) {
|
|
54
|
+
if (detectBinary("mcporter") || detectBinary("openclaw")) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return existsSync(configPath) || existsSync(dirname(configPath));
|
|
58
|
+
}
|
|
59
|
+
export function writeOpenClawConfig(serverName, launcherCommand, configPath = getOpenClawConfigPath()) {
|
|
60
|
+
const existingText = existsSync(configPath) ? readFileSync(configPath, "utf8") : null;
|
|
61
|
+
const created = existingText === null;
|
|
62
|
+
const nextText = upsertOpenClawConfigText(existingText, serverName, launcherCommand);
|
|
63
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
64
|
+
writeFileSync(configPath, `${nextText}\n`, "utf8");
|
|
65
|
+
return created ? "created" : "updated";
|
|
66
|
+
}
|
|
67
|
+
export function removeOpenClawConfig(serverName, configPath = getOpenClawConfigPath()) {
|
|
68
|
+
const existingText = existsSync(configPath) ? readFileSync(configPath, "utf8") : null;
|
|
69
|
+
const result = removeOpenClawConfigEntryText(existingText, serverName);
|
|
70
|
+
if (!result.changed) {
|
|
71
|
+
return "missing";
|
|
72
|
+
}
|
|
73
|
+
if (!result.nextText) {
|
|
74
|
+
rmSync(configPath, { force: true });
|
|
75
|
+
return "removed";
|
|
76
|
+
}
|
|
77
|
+
writeFileSync(configPath, `${result.nextText}\n`, "utf8");
|
|
78
|
+
return "removed";
|
|
79
|
+
}
|
package/dist/utils/videoInput.js
CHANGED
|
@@ -13,6 +13,9 @@ function extractFromUrl(raw) {
|
|
|
13
13
|
}
|
|
14
14
|
const host = url.hostname.toLowerCase();
|
|
15
15
|
const normalizedHost = host.startsWith("www.") ? host.slice(4) : host;
|
|
16
|
+
if (url.searchParams.has("list")) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
16
19
|
if (normalizedHost === "youtu.be") {
|
|
17
20
|
const id = url.pathname.split("/").filter(Boolean)[0] ?? "";
|
|
18
21
|
return toValidVideoId(id);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils/ytdlp.d.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
export interface YtDlpResult {
|
|
2
3
|
exitCode: number;
|
|
3
4
|
stdout: string;
|
|
4
5
|
stderr: string;
|
|
5
6
|
durationMs: number;
|
|
6
7
|
}
|
|
7
|
-
export
|
|
8
|
+
export interface YtDlpStderrLineSplitter {
|
|
9
|
+
push(chunk: Buffer | string): void;
|
|
10
|
+
flush(): void;
|
|
11
|
+
}
|
|
12
|
+
type SpawnSyncFn = typeof spawnSync;
|
|
13
|
+
export declare function createYtDlpStderrLineSplitter(onLine: (line: string) => void): YtDlpStderrLineSplitter;
|
|
14
|
+
export declare function hasYtDlp(runSpawnSync?: SpawnSyncFn): boolean;
|
|
15
|
+
export declare function runYtDlp(args: string[], timeoutMs?: number, onStderrLine?: (line: string) => void): Promise<YtDlpResult>;
|
|
16
|
+
export declare function runYtDlpJson<T>(args: string[], timeoutMs?: number): Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
value: T;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
error: string;
|
|
22
|
+
}>;
|
|
23
|
+
export {};
|