@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.
Files changed (66) hide show
  1. package/README.md +44 -3
  2. package/dist/auth/sharedAuth.d.ts +10 -0
  3. package/dist/auth/sharedAuth.js +24 -0
  4. package/dist/auth/tokenManager.d.ts +10 -1
  5. package/dist/auth/tokenManager.js +14 -22
  6. package/dist/cli/check.js +6 -3
  7. package/dist/cli/index.d.ts +15 -1
  8. package/dist/cli/index.js +74 -31
  9. package/dist/cli/runtime.d.ts +9 -0
  10. package/dist/cli/runtime.js +35 -0
  11. package/dist/cli/serve.js +3 -1
  12. package/dist/cli/setup.js +60 -61
  13. package/dist/cli/setupCookies.js +2 -2
  14. package/dist/cli/smoke.d.ts +27 -0
  15. package/dist/cli/smoke.js +108 -0
  16. package/dist/cli/uninstall.d.ts +1 -0
  17. package/dist/cli/uninstall.js +67 -0
  18. package/dist/download/downloader.d.ts +64 -0
  19. package/dist/download/downloader.js +264 -0
  20. package/dist/download/jobManager.d.ts +21 -0
  21. package/dist/download/jobManager.js +198 -0
  22. package/dist/download/types.d.ts +43 -0
  23. package/dist/download/types.js +1 -0
  24. package/dist/runtime/ffmpegRuntime.d.ts +13 -0
  25. package/dist/runtime/ffmpegRuntime.js +51 -0
  26. package/dist/runtime/installers.d.ts +12 -0
  27. package/dist/runtime/installers.js +45 -0
  28. package/dist/runtime/manifest.d.ts +18 -0
  29. package/dist/runtime/manifest.js +43 -0
  30. package/dist/runtime/playwrightRuntime.d.ts +13 -0
  31. package/dist/runtime/playwrightRuntime.js +37 -0
  32. package/dist/runtime/systemDeps.d.ts +3 -0
  33. package/dist/runtime/systemDeps.js +30 -0
  34. package/dist/runtime/ytdlpRuntime.d.ts +14 -0
  35. package/dist/runtime/ytdlpRuntime.js +58 -0
  36. package/dist/server.d.ts +3 -1
  37. package/dist/server.js +4 -1
  38. package/dist/tools/downloads.d.ts +11 -0
  39. package/dist/tools/downloads.js +220 -0
  40. package/dist/tools/subtitles.d.ts +25 -0
  41. package/dist/tools/subtitles.js +135 -47
  42. package/dist/utils/config.d.ts +28 -0
  43. package/dist/utils/config.js +40 -11
  44. package/dist/utils/ffmpeg.d.ts +5 -0
  45. package/dist/utils/ffmpeg.js +16 -0
  46. package/dist/utils/ffmpegPath.d.ts +8 -0
  47. package/dist/utils/ffmpegPath.js +21 -0
  48. package/dist/utils/formatters.d.ts +4 -0
  49. package/dist/utils/formatters.js +42 -0
  50. package/dist/utils/mediaPaths.d.ts +7 -0
  51. package/dist/utils/mediaPaths.js +10 -0
  52. package/dist/utils/openClaw.d.ts +17 -0
  53. package/dist/utils/openClaw.js +79 -0
  54. package/dist/utils/videoInput.js +3 -0
  55. package/dist/utils/videoMetadata.d.ts +11 -0
  56. package/dist/utils/videoMetadata.js +1 -0
  57. package/dist/utils/ytdlp.d.ts +17 -1
  58. package/dist/utils/ytdlp.js +89 -2
  59. package/dist/utils/ytdlpPath.d.ts +9 -2
  60. package/dist/utils/ytdlpPath.js +19 -20
  61. package/dist/utils/ytdlpProgress.d.ts +13 -0
  62. package/dist/utils/ytdlpProgress.js +77 -0
  63. package/package.json +5 -3
  64. package/scripts/download-ytdlp.mjs +1 -1
  65. package/scripts/install.ps1 +9 -0
  66. package/scripts/install.sh +15 -0
@@ -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
- mkdirSync(PATHS.subtitlesDir, { recursive: true });
185
- const outTemplate = join(PATHS.subtitlesDir, `${todayDateStr()}_${videoId}_${lang}`);
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 = PATHS.subtitlesDir;
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
- // 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
- }
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
- // 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
- }
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", "YouTube cookies have expired and auto-refresh failed.\n" +
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
- // 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
- }
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
- // Try headless auto-refresh
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", "YouTube cookies have expired and auto-refresh failed.\n" +
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) {
@@ -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) */
@@ -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
- const CONFIG_DIR = join(homedir(), ".yt-mcp");
5
- const SUBTITLES_DIR = join(homedir(), "Downloads", "yt-mcp");
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
- configDir: CONFIG_DIR,
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(CONFIG_DIR, { recursive: true });
55
+ mkdirSync(PATHS.configDir, { recursive: true });
27
56
  }
28
57
  export function loadConfig() {
29
58
  ensureConfigDir();
@@ -0,0 +1,5 @@
1
+ import { spawnSync } from "node:child_process";
2
+ type SpawnSyncFn = typeof spawnSync;
3
+ export declare function hasFfmpeg(runSpawnSync?: SpawnSyncFn): boolean;
4
+ export declare function isRuntimeMissingMessage(binary: "ffmpeg" | "yt-dlp"): string;
5
+ export {};
@@ -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,4 @@
1
+ export declare function formatPercent(value: number): string;
2
+ export declare function formatBytes(bytes: number): string;
3
+ export declare function formatSpeed(bytesPerSecond: number): string;
4
+ export declare function formatEta(seconds: number): string;
@@ -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,7 @@
1
+ export interface MediaOutputPaths {
2
+ outputDir: string;
3
+ videoFile: string;
4
+ metadataFile: string;
5
+ subtitlesDir: string;
6
+ }
7
+ export declare function buildMediaOutputPaths(rootDir: string, dateStr: string, videoId: string): MediaOutputPaths;
@@ -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
+ }
@@ -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,11 @@
1
+ export interface PreflightVideoMetadata {
2
+ video_id: string;
3
+ source_url: string;
4
+ title: string;
5
+ channel_title?: string;
6
+ channel_id?: string;
7
+ description?: string;
8
+ duration?: string;
9
+ published_at?: string;
10
+ thumbnail_url?: string;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 declare function runYtDlp(args: string[], timeoutMs?: number): Promise<YtDlpResult>;
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 {};