@mkterswingman/5mghost-yonder 0.0.1 → 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.d.ts +4 -0
- package/dist/cli/check.js +90 -0
- package/dist/cli/index.d.ts +15 -1
- package/dist/cli/index.js +76 -27
- 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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { PATHS } from "../utils/config.js";
|
|
4
|
+
export function createEmptyRuntimeManifest() {
|
|
5
|
+
return {
|
|
6
|
+
schema_version: 1,
|
|
7
|
+
components: {},
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function loadRuntimeManifest(manifestPath = PATHS.runtimeManifestJson) {
|
|
11
|
+
if (!existsSync(manifestPath)) {
|
|
12
|
+
return createEmptyRuntimeManifest();
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(manifestPath, "utf8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed.schema_version !== 1 || parsed.components == null || typeof parsed.components !== "object") {
|
|
18
|
+
return createEmptyRuntimeManifest();
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
schema_version: 1,
|
|
22
|
+
components: parsed.components,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return createEmptyRuntimeManifest();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function saveRuntimeManifest(manifestPath = PATHS.runtimeManifestJson, manifest) {
|
|
30
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
31
|
+
const tempPath = `${manifestPath}.tmp`;
|
|
32
|
+
writeFileSync(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
33
|
+
renameSync(tempPath, manifestPath);
|
|
34
|
+
}
|
|
35
|
+
export function updateRuntimeComponent(manifest, name, state) {
|
|
36
|
+
return {
|
|
37
|
+
schema_version: 1,
|
|
38
|
+
components: {
|
|
39
|
+
...manifest.components,
|
|
40
|
+
[name]: state,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RuntimeComponentState } from "./manifest.js";
|
|
2
|
+
export declare function checkPlaywrightRuntime(): Promise<RuntimeComponentState & {
|
|
3
|
+
name: "playwright";
|
|
4
|
+
message?: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function installPlaywrightRuntime(): Promise<RuntimeComponentState & {
|
|
7
|
+
name: "playwright";
|
|
8
|
+
message?: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function updatePlaywrightRuntime(): Promise<RuntimeComponentState & {
|
|
11
|
+
name: "playwright";
|
|
12
|
+
message?: string;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
export async function checkPlaywrightRuntime() {
|
|
4
|
+
try {
|
|
5
|
+
const { chromium } = await import("playwright");
|
|
6
|
+
const executablePath = chromium.executablePath();
|
|
7
|
+
if (existsSync(executablePath)) {
|
|
8
|
+
return {
|
|
9
|
+
name: "playwright",
|
|
10
|
+
status: "installed",
|
|
11
|
+
version: executablePath.split("/").pop() ?? "chromium",
|
|
12
|
+
source: "runtime",
|
|
13
|
+
installed_at: null,
|
|
14
|
+
binary_path: executablePath,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// fall through to missing state
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
name: "playwright",
|
|
23
|
+
status: "missing",
|
|
24
|
+
version: null,
|
|
25
|
+
source: "runtime",
|
|
26
|
+
installed_at: null,
|
|
27
|
+
binary_path: null,
|
|
28
|
+
message: "npx playwright install chromium",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function installPlaywrightRuntime() {
|
|
32
|
+
execSync("npx playwright install chromium", { stdio: "inherit" });
|
|
33
|
+
return checkPlaywrightRuntime();
|
|
34
|
+
}
|
|
35
|
+
export async function updatePlaywrightRuntime() {
|
|
36
|
+
return installPlaywrightRuntime();
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export function hasCommand(name) {
|
|
3
|
+
try {
|
|
4
|
+
execSync(`${name} --version`, { stdio: "ignore" });
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function getFfmpegInstallHint(platform = process.platform) {
|
|
12
|
+
switch (platform) {
|
|
13
|
+
case "darwin":
|
|
14
|
+
return "brew install ffmpeg";
|
|
15
|
+
case "win32":
|
|
16
|
+
return "winget install Gyan.FFmpeg";
|
|
17
|
+
default:
|
|
18
|
+
return "sudo apt update && sudo apt install -y ffmpeg";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function getNodeInstallHint(platform = process.platform) {
|
|
22
|
+
switch (platform) {
|
|
23
|
+
case "darwin":
|
|
24
|
+
return "brew install node";
|
|
25
|
+
case "win32":
|
|
26
|
+
return "winget install OpenJS.NodeJS.LTS";
|
|
27
|
+
default:
|
|
28
|
+
return "sudo apt update && sudo apt install -y nodejs npm";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RuntimeComponentState } from "./manifest.js";
|
|
2
|
+
export declare function buildYtDlpInstallCommand(): string;
|
|
3
|
+
export declare function checkYtDlpRuntime(): RuntimeComponentState & {
|
|
4
|
+
name: "yt-dlp";
|
|
5
|
+
message?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function installYtDlpRuntime(): Promise<RuntimeComponentState & {
|
|
8
|
+
name: "yt-dlp";
|
|
9
|
+
message?: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function updateYtDlpRuntime(): Promise<RuntimeComponentState & {
|
|
12
|
+
name: "yt-dlp";
|
|
13
|
+
message?: string;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { PATHS } from "../utils/config.js";
|
|
5
|
+
import { getRuntimeYtDlpPath, getYtDlpVersion } from "../utils/ytdlpPath.js";
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkgRoot = join(__dirname, "..", "..");
|
|
8
|
+
export function buildYtDlpInstallCommand() {
|
|
9
|
+
return "node scripts/download-ytdlp.mjs";
|
|
10
|
+
}
|
|
11
|
+
export function checkYtDlpRuntime() {
|
|
12
|
+
const runtimePath = getRuntimeYtDlpPath();
|
|
13
|
+
const runtimeInfo = getYtDlpVersion(runtimePath);
|
|
14
|
+
if (runtimeInfo) {
|
|
15
|
+
return {
|
|
16
|
+
name: "yt-dlp",
|
|
17
|
+
status: "installed",
|
|
18
|
+
version: runtimeInfo.version,
|
|
19
|
+
source: runtimeInfo.source,
|
|
20
|
+
installed_at: null,
|
|
21
|
+
binary_path: runtimePath,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const systemInfo = getYtDlpVersion("yt-dlp");
|
|
25
|
+
if (systemInfo) {
|
|
26
|
+
return {
|
|
27
|
+
name: "yt-dlp",
|
|
28
|
+
status: "installed",
|
|
29
|
+
version: systemInfo.version,
|
|
30
|
+
source: systemInfo.source,
|
|
31
|
+
installed_at: null,
|
|
32
|
+
binary_path: "yt-dlp",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: "yt-dlp",
|
|
37
|
+
status: "missing",
|
|
38
|
+
version: null,
|
|
39
|
+
source: "runtime",
|
|
40
|
+
installed_at: null,
|
|
41
|
+
binary_path: null,
|
|
42
|
+
message: "yt-mcp runtime install",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function installYtDlpRuntime() {
|
|
46
|
+
execSync(buildYtDlpInstallCommand(), {
|
|
47
|
+
cwd: pkgRoot,
|
|
48
|
+
stdio: "inherit",
|
|
49
|
+
env: {
|
|
50
|
+
...process.env,
|
|
51
|
+
YT_MCP_BINARY_DIR: PATHS.runtimeBinDir,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return checkYtDlpRuntime();
|
|
55
|
+
}
|
|
56
|
+
export async function updateYtDlpRuntime() {
|
|
57
|
+
return installYtDlpRuntime();
|
|
58
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { YtMcpConfig } from "./utils/config.js";
|
|
3
3
|
import type { TokenManager } from "./auth/tokenManager.js";
|
|
4
|
+
import { DownloadJobManager } from "./download/jobManager.js";
|
|
5
|
+
import type { DownloadToolDeps } from "./tools/downloads.js";
|
|
4
6
|
/**
|
|
5
7
|
* Creates the MCP server.
|
|
6
8
|
*
|
|
@@ -18,4 +20,4 @@ import type { TokenManager } from "./auth/tokenManager.js";
|
|
|
18
20
|
* ├─ PAT login URL (user clicks to get token)
|
|
19
21
|
* └─ setup command (for full OAuth + cookies)
|
|
20
22
|
*/
|
|
21
|
-
export declare function createServer(config: YtMcpConfig, tokenManager: TokenManager): Promise<McpServer>;
|
|
23
|
+
export declare function createServer(config: YtMcpConfig, tokenManager: TokenManager, downloadJobManager?: DownloadJobManager, downloadToolDeps?: DownloadToolDeps): Promise<McpServer>;
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { DownloadJobManager } from "./download/jobManager.js";
|
|
2
3
|
import { registerSubtitleTools } from "./tools/subtitles.js";
|
|
4
|
+
import { registerDownloadTools } from "./tools/downloads.js";
|
|
3
5
|
import { registerRemoteTools } from "./tools/remote.js";
|
|
4
6
|
/**
|
|
5
7
|
* Creates the MCP server.
|
|
@@ -18,7 +20,7 @@ import { registerRemoteTools } from "./tools/remote.js";
|
|
|
18
20
|
* ├─ PAT login URL (user clicks to get token)
|
|
19
21
|
* └─ setup command (for full OAuth + cookies)
|
|
20
22
|
*/
|
|
21
|
-
export async function createServer(config, tokenManager) {
|
|
23
|
+
export async function createServer(config, tokenManager, downloadJobManager = new DownloadJobManager(), downloadToolDeps = {}) {
|
|
22
24
|
const server = new McpServer({
|
|
23
25
|
name: "@mkterswingman/yt-mcp",
|
|
24
26
|
version: "0.1.0",
|
|
@@ -74,6 +76,7 @@ export async function createServer(config, tokenManager) {
|
|
|
74
76
|
}
|
|
75
77
|
// Authenticated — register all tools
|
|
76
78
|
registerSubtitleTools(server, config, tokenManager);
|
|
79
|
+
registerDownloadTools(server, config, tokenManager, downloadJobManager, downloadToolDeps);
|
|
77
80
|
registerRemoteTools(server, config, tokenManager);
|
|
78
81
|
return server;
|
|
79
82
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { YtMcpConfig } from "../utils/config.js";
|
|
3
|
+
import type { TokenManager } from "../auth/tokenManager.js";
|
|
4
|
+
import { downloadOneItem } from "../download/downloader.js";
|
|
5
|
+
import type { DownloadJobManager } from "../download/jobManager.js";
|
|
6
|
+
export interface DownloadToolDeps {
|
|
7
|
+
executeItem?: typeof downloadOneItem;
|
|
8
|
+
mediaRootDir?: string;
|
|
9
|
+
nowDate?: () => string;
|
|
10
|
+
}
|
|
11
|
+
export declare function registerDownloadTools(server: McpServer, config: YtMcpConfig, tokenManager: TokenManager, manager: DownloadJobManager, deps?: DownloadToolDeps): void;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MEDIA_ROOT_DIR } from "../utils/config.js";
|
|
3
|
+
import { downloadOneItem } from "../download/downloader.js";
|
|
4
|
+
import { buildMediaOutputPaths } from "../utils/mediaPaths.js";
|
|
5
|
+
import { resolveVideoInput } from "../utils/videoInput.js";
|
|
6
|
+
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
7
|
+
const DOWNLOAD_MODES = ["video", "subtitles", "both"];
|
|
8
|
+
const VIDEO_QUALITIES = ["1080p", "max"];
|
|
9
|
+
const SUBTITLE_FORMATS = ["vtt", "srt", "ttml", "srv3", "csv"];
|
|
10
|
+
const MAX_DOWNLOAD_VIDEOS = 5;
|
|
11
|
+
class DownloadToolError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "DownloadToolError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function toolOk(payload) {
|
|
20
|
+
return {
|
|
21
|
+
structuredContent: payload,
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: JSON.stringify(payload),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function toolErr(code, message) {
|
|
31
|
+
const payload = { status: "failed", error: { code, message } };
|
|
32
|
+
return {
|
|
33
|
+
structuredContent: payload,
|
|
34
|
+
isError: true,
|
|
35
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function todayDateStr() {
|
|
39
|
+
const d = new Date();
|
|
40
|
+
const yyyy = d.getFullYear();
|
|
41
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
42
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
43
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
44
|
+
}
|
|
45
|
+
function dedupeValues(values) {
|
|
46
|
+
return values == null ? undefined : [...new Set(values)];
|
|
47
|
+
}
|
|
48
|
+
function toSourceUrl(input, videoId) {
|
|
49
|
+
const trimmed = input.trim();
|
|
50
|
+
if (/^https?:\/\//u.test(trimmed)) {
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
54
|
+
}
|
|
55
|
+
function normalizeSubtitleFormats(mode, subtitleFormats) {
|
|
56
|
+
if (mode === "video") {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const normalized = dedupeValues(subtitleFormats) ?? ["srt"];
|
|
60
|
+
if (normalized.length === 0) {
|
|
61
|
+
throw new DownloadToolError("INVALID_INPUT", "subtitle_formats must include at least one format when mode includes subtitles");
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
function buildJobInput(videos, mode, videoQuality, subtitleFormats, deps) {
|
|
66
|
+
const invalidInputs = [];
|
|
67
|
+
const resolvedVideos = [];
|
|
68
|
+
const seenIds = new Set();
|
|
69
|
+
const dateStr = deps.nowDate?.() ?? todayDateStr();
|
|
70
|
+
const mediaRootDir = deps.mediaRootDir ?? MEDIA_ROOT_DIR;
|
|
71
|
+
for (const rawInput of videos) {
|
|
72
|
+
const videoId = resolveVideoInput(rawInput);
|
|
73
|
+
if (!videoId) {
|
|
74
|
+
invalidInputs.push(rawInput);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (seenIds.has(videoId)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
seenIds.add(videoId);
|
|
81
|
+
resolvedVideos.push({
|
|
82
|
+
video_id: videoId,
|
|
83
|
+
// Why: yt-dlp should keep the original URL when the user passed one so polls reflect the submitted source.
|
|
84
|
+
source_url: toSourceUrl(rawInput, videoId),
|
|
85
|
+
title: videoId,
|
|
86
|
+
output_dir: buildMediaOutputPaths(mediaRootDir, dateStr, videoId).outputDir,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (resolvedVideos.length === 0) {
|
|
90
|
+
throw new DownloadToolError("INVALID_INPUT", `无法解析任何视频 ID。无效输入: ${invalidInputs.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
jobInput: {
|
|
94
|
+
videos: resolvedVideos,
|
|
95
|
+
mode,
|
|
96
|
+
video_quality: mode === "subtitles" ? undefined : videoQuality,
|
|
97
|
+
subtitle_formats: normalizeSubtitleFormats(mode, subtitleFormats),
|
|
98
|
+
},
|
|
99
|
+
invalidInputs,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async function runDownloadJob(manager, snapshot, subtitleLanguages, defaultSubtitleLanguages, deps) {
|
|
103
|
+
const executeItem = deps.executeItem ?? downloadOneItem;
|
|
104
|
+
for (const item of snapshot.items) {
|
|
105
|
+
try {
|
|
106
|
+
await executeItem({
|
|
107
|
+
jobManager: manager,
|
|
108
|
+
jobId: snapshot.job_id,
|
|
109
|
+
item,
|
|
110
|
+
mode: snapshot.mode,
|
|
111
|
+
videoQuality: snapshot.video_quality,
|
|
112
|
+
subtitleFormats: snapshot.subtitle_formats,
|
|
113
|
+
subtitleLanguages,
|
|
114
|
+
defaultSubtitleLanguages,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// downloadOneItem already records per-item failures in the manager.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const latest = manager.pollJob(snapshot.job_id);
|
|
122
|
+
if (latest.status !== "completed" && latest.status !== "failed") {
|
|
123
|
+
const stalledItems = latest.items.filter((item) => item.status === "queued");
|
|
124
|
+
for (const item of stalledItems) {
|
|
125
|
+
manager.failItem(snapshot.job_id, item.video_id, "Download job stopped before this item started");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function registerDownloadTools(server, config, tokenManager, manager, deps = {}) {
|
|
130
|
+
const pendingJobs = [];
|
|
131
|
+
let draining = false;
|
|
132
|
+
async function requireAuth() {
|
|
133
|
+
const token = await tokenManager.getValidToken();
|
|
134
|
+
if (!token)
|
|
135
|
+
return AUTH_REQUIRED_MSG;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
async function drainQueue() {
|
|
139
|
+
if (draining) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
draining = true;
|
|
143
|
+
try {
|
|
144
|
+
while (pendingJobs.length > 0) {
|
|
145
|
+
const nextJob = pendingJobs.shift();
|
|
146
|
+
if (!nextJob) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
await runDownloadJob(manager, nextJob.snapshot, nextJob.subtitleLanguages, nextJob.defaultSubtitleLanguages, deps);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
draining = false;
|
|
154
|
+
if (pendingJobs.length > 0) {
|
|
155
|
+
void drainQueue();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function enqueueDownloadJob(snapshot, subtitleLanguages, defaultSubtitleLanguages) {
|
|
160
|
+
pendingJobs.push({
|
|
161
|
+
snapshot,
|
|
162
|
+
subtitleLanguages,
|
|
163
|
+
defaultSubtitleLanguages,
|
|
164
|
+
});
|
|
165
|
+
void drainQueue();
|
|
166
|
+
}
|
|
167
|
+
server.registerTool("start_download_job", {
|
|
168
|
+
description: "Start a local media download job for up to 5 YouTube videos. Supports video, subtitles, or both and returns a job snapshot for polling.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
videos: z.array(z.string().min(1)).min(1).max(MAX_DOWNLOAD_VIDEOS),
|
|
171
|
+
mode: z.enum(DOWNLOAD_MODES).optional(),
|
|
172
|
+
video_quality: z.enum(VIDEO_QUALITIES).optional(),
|
|
173
|
+
subtitle_formats: z.array(z.enum(SUBTITLE_FORMATS)).min(1).optional(),
|
|
174
|
+
subtitle_languages: z.array(z.string().min(1)).min(1).optional(),
|
|
175
|
+
},
|
|
176
|
+
}, async ({ videos, mode, video_quality, subtitle_formats, subtitle_languages }) => {
|
|
177
|
+
const authErr = await requireAuth();
|
|
178
|
+
if (authErr)
|
|
179
|
+
return toolErr("AUTH_REQUIRED", authErr);
|
|
180
|
+
if (!Array.isArray(videos) || videos.length === 0) {
|
|
181
|
+
return toolErr("INVALID_INPUT", "videos must include at least one input");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const resolvedMode = mode ?? "both";
|
|
185
|
+
const { jobInput, invalidInputs } = buildJobInput(videos, resolvedMode, video_quality, subtitle_formats, deps);
|
|
186
|
+
const snapshot = manager.createJob(jobInput);
|
|
187
|
+
enqueueDownloadJob(snapshot, dedupeValues(subtitle_languages), resolvedMode === "video" ? undefined : [...config.default_languages]);
|
|
188
|
+
const queuedSnapshot = manager.pollJob(snapshot.job_id);
|
|
189
|
+
return toolOk({
|
|
190
|
+
...queuedSnapshot,
|
|
191
|
+
invalid_inputs: invalidInputs.length > 0 ? invalidInputs : undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof DownloadToolError) {
|
|
196
|
+
return toolErr(error.code, error.message);
|
|
197
|
+
}
|
|
198
|
+
return toolErr("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
server.registerTool("poll_download_job", {
|
|
202
|
+
description: "Poll a local media download job and return the current whole-job snapshot.",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
job_id: z.string().min(1),
|
|
205
|
+
},
|
|
206
|
+
}, async ({ job_id }) => {
|
|
207
|
+
const authErr = await requireAuth();
|
|
208
|
+
if (authErr)
|
|
209
|
+
return toolErr("AUTH_REQUIRED", authErr);
|
|
210
|
+
if (!job_id) {
|
|
211
|
+
return toolErr("INVALID_INPUT", "job_id is required");
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
return toolOk(manager.pollJob(job_id));
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return toolErr("JOB_NOT_FOUND", error instanceof Error ? error.message : String(error));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { YtMcpConfig } from "../utils/config.js";
|
|
3
3
|
import type { TokenManager } from "../auth/tokenManager.js";
|
|
4
|
+
export declare function toReadableSubtitleJobError(error: unknown): string;
|
|
5
|
+
/**
|
|
6
|
+
* Convert VTT subtitle content to clean, human-readable CSV.
|
|
7
|
+
*
|
|
8
|
+
* YouTube auto-captions use a "rolling" VTT format where each cue has two
|
|
9
|
+
* lines: the first line repeats the previous cue's text, and the second line
|
|
10
|
+
* contains new words (marked with <c> tags for word-level timing). This
|
|
11
|
+
* function detects and handles this pattern:
|
|
12
|
+
*
|
|
13
|
+
* 1. Detects auto-caption format (presence of <c> word-timing tags)
|
|
14
|
+
* 2. For auto-captions: extracts only the NEW text from each cue's second
|
|
15
|
+
* line, skips transition cues, and concatenates into clean sentences
|
|
16
|
+
* 3. For manual subtitles: passes through cleanly with no data loss
|
|
17
|
+
* 4. Outputs: start_time, end_time, text
|
|
18
|
+
*/
|
|
19
|
+
export declare function vttToCsv(vtt: string): string;
|
|
20
|
+
export declare function downloadSubtitlesForLanguages(input: {
|
|
21
|
+
videoId: string;
|
|
22
|
+
sourceUrl?: string;
|
|
23
|
+
languages: string[];
|
|
24
|
+
formats: string[];
|
|
25
|
+
subtitlesDir: string;
|
|
26
|
+
skipMissingLanguages?: boolean;
|
|
27
|
+
onProgress?: (completed: number, total: number) => void;
|
|
28
|
+
}): Promise<Record<string, string[]>>;
|
|
4
29
|
export declare function registerSubtitleTools(server: McpServer, config: YtMcpConfig, tokenManager: TokenManager): void;
|