@mkterswingman/5mghost-yonder 0.0.37 → 0.0.39
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/check.js +6 -2
- package/dist/cli/index.js +14 -1
- package/dist/cli/installSkills.js +21 -12
- package/dist/cli/serve.js +7 -3
- package/dist/cli/setup.js +50 -32
- package/dist/cli/setupCookies.d.ts +1 -4
- package/dist/cli/setupCookies.js +15 -2
- package/dist/cli/uninstall.js +21 -18
- package/dist/contracts/youtubeToolContracts.d.ts +17 -0
- package/dist/contracts/youtubeToolContracts.js +189 -0
- package/dist/server.d.ts +1 -1
- package/dist/tools/downloads.d.ts +1 -1
- package/dist/tools/remote.d.ts +1 -1
- package/dist/tools/remote.js +32 -51
- package/dist/tools/subtitles/cookieSession.d.ts +22 -0
- package/dist/tools/subtitles/cookieSession.js +66 -0
- package/dist/tools/subtitles/download.d.ts +24 -0
- package/dist/tools/subtitles/download.js +169 -0
- package/dist/tools/subtitles/parse.d.ts +1 -0
- package/dist/tools/subtitles/parse.js +106 -0
- package/dist/tools/subtitles.d.ts +5 -26
- package/dist/tools/subtitles.js +7 -389
- package/dist/utils/codeBuddy.d.ts +8 -0
- package/dist/utils/codeBuddy.js +62 -0
- package/dist/utils/cookieRefresh.js +7 -0
- package/dist/utils/launcher.d.ts +6 -11
- package/dist/utils/launcher.js +11 -82
- package/dist/utils/workBuddy.d.ts +8 -0
- package/dist/utils/workBuddy.js +62 -0
- package/package.json +6 -1
- package/dist/auth/oauthFlow.d.ts +0 -9
- package/dist/auth/oauthFlow.js +0 -151
- package/dist/auth/sharedAuth.d.ts +0 -10
- package/dist/auth/sharedAuth.js +0 -31
- package/dist/auth/tokenManager.d.ts +0 -18
- package/dist/auth/tokenManager.js +0 -92
- package/dist/utils/codexInternal.d.ts +0 -9
- package/dist/utils/codexInternal.js +0 -60
- package/dist/utils/mcpRegistration.d.ts +0 -7
- package/dist/utils/mcpRegistration.js +0 -23
- package/dist/utils/openClaw.d.ts +0 -18
- package/dist/utils/openClaw.js +0 -82
- package/dist/utils/skills.d.ts +0 -16
- package/dist/utils/skills.js +0 -56
package/dist/tools/remote.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { YOUTUBE_TOOL_CONTRACTS } from "../contracts/youtubeToolContracts.js";
|
|
2
3
|
function toolErr(code, message) {
|
|
3
4
|
const payload = { status: "failed", error: { code, message } };
|
|
4
5
|
return {
|
|
@@ -69,9 +70,8 @@ function createRemoteTool(server, def, tokenManager, apiUrl) {
|
|
|
69
70
|
}
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
|
-
const
|
|
73
|
-
{
|
|
74
|
-
name: "search_videos",
|
|
73
|
+
const REMOTE_TOOL_META = {
|
|
74
|
+
search_videos: {
|
|
75
75
|
description: "Search YouTube videos by keyword. Supports up to 300 results.",
|
|
76
76
|
schema: {
|
|
77
77
|
query: z.string().min(1),
|
|
@@ -87,42 +87,32 @@ const REMOTE_TOOLS = [
|
|
|
87
87
|
])
|
|
88
88
|
.optional(),
|
|
89
89
|
},
|
|
90
|
-
remotePath: "api/search",
|
|
91
90
|
},
|
|
92
|
-
{
|
|
93
|
-
name: "get_video_stats",
|
|
91
|
+
get_video_stats: {
|
|
94
92
|
description: "Get YouTube video stats in one request. Accepts up to 1000 video IDs/URLs.",
|
|
95
93
|
schema: {
|
|
96
94
|
videos: z.array(z.string().min(1)).min(1).max(1000),
|
|
97
95
|
},
|
|
98
|
-
remotePath: "api/video-stats/bulk",
|
|
99
96
|
},
|
|
100
|
-
{
|
|
101
|
-
name: "start_video_stats_job",
|
|
97
|
+
start_video_stats_job: {
|
|
102
98
|
description: "Start an async YouTube video stats job. Accepts up to 1000 items.",
|
|
103
99
|
schema: {
|
|
104
100
|
videos: z.array(z.string().min(1)).min(1).max(1000),
|
|
105
101
|
},
|
|
106
|
-
remotePath: "api/video-stats/job/start",
|
|
107
102
|
},
|
|
108
|
-
{
|
|
109
|
-
name: "poll_video_stats_job",
|
|
103
|
+
poll_video_stats_job: {
|
|
110
104
|
description: "Poll job progress and get partial/final results for a job_id.",
|
|
111
105
|
schema: {
|
|
112
106
|
job_id: z.string().min(1),
|
|
113
107
|
},
|
|
114
|
-
remotePath: "api/video-stats/job/poll",
|
|
115
108
|
},
|
|
116
|
-
{
|
|
117
|
-
name: "resume_video_stats_job",
|
|
109
|
+
resume_video_stats_job: {
|
|
118
110
|
description: "Resume a previously partial job with a resume_token.",
|
|
119
111
|
schema: {
|
|
120
112
|
resume_token: z.string().min(1),
|
|
121
113
|
},
|
|
122
|
-
remotePath: "api/video-stats/job/resume",
|
|
123
114
|
},
|
|
124
|
-
{
|
|
125
|
-
name: "get_trending",
|
|
115
|
+
get_trending: {
|
|
126
116
|
description: "Get official YouTube mostPopular videos. Supports region/category filters.",
|
|
127
117
|
schema: {
|
|
128
118
|
region_code: z
|
|
@@ -132,38 +122,30 @@ const REMOTE_TOOLS = [
|
|
|
132
122
|
category_id: z.string().min(1).max(64).optional(),
|
|
133
123
|
max_results: z.number().int().min(1).max(300).optional(),
|
|
134
124
|
},
|
|
135
|
-
remotePath: "api/trending",
|
|
136
125
|
},
|
|
137
|
-
{
|
|
138
|
-
name: "search_channels",
|
|
126
|
+
search_channels: {
|
|
139
127
|
description: "Search YouTube channels by keyword for candidate discovery.",
|
|
140
128
|
schema: {
|
|
141
129
|
query: z.string().min(1).max(200),
|
|
142
130
|
max_results: z.number().int().min(1).max(50).optional(),
|
|
143
131
|
},
|
|
144
|
-
remotePath: "api/channel/search",
|
|
145
132
|
},
|
|
146
|
-
{
|
|
147
|
-
name: "get_channel_stats",
|
|
133
|
+
get_channel_stats: {
|
|
148
134
|
description: "Get YouTube channel statistics for up to 50 channel inputs.",
|
|
149
135
|
schema: {
|
|
150
136
|
channels: z.array(z.string().min(1)).min(1).max(50).optional(),
|
|
151
137
|
channel_ids: z.array(z.string().min(1)).min(1).max(50).optional(),
|
|
152
138
|
},
|
|
153
|
-
remotePath: "api/channel/stats",
|
|
154
139
|
},
|
|
155
|
-
{
|
|
156
|
-
name: "list_channel_uploads",
|
|
140
|
+
list_channel_uploads: {
|
|
157
141
|
description: "List videos uploaded by a YouTube channel. Supports up to 1000 results.",
|
|
158
142
|
schema: {
|
|
159
143
|
channel: z.string().min(1).optional(),
|
|
160
144
|
channel_id: z.string().min(1).optional(),
|
|
161
145
|
max_results: z.number().int().min(1).max(1000).optional(),
|
|
162
146
|
},
|
|
163
|
-
remotePath: "api/channel/uploads",
|
|
164
147
|
},
|
|
165
|
-
{
|
|
166
|
-
name: "get_comments",
|
|
148
|
+
get_comments: {
|
|
167
149
|
description: "Get comments for one YouTube video. max_comments up to 1000.",
|
|
168
150
|
schema: {
|
|
169
151
|
video: z.string().min(1),
|
|
@@ -173,46 +155,36 @@ const REMOTE_TOOLS = [
|
|
|
173
155
|
replies_preview_per_comment: z.number().int().min(1).max(5).optional(),
|
|
174
156
|
replies_preview_parent_limit: z.number().int().min(1).max(25).optional(),
|
|
175
157
|
},
|
|
176
|
-
remotePath: "api/comments/sync",
|
|
177
158
|
},
|
|
178
|
-
{
|
|
179
|
-
name: "start_comments_job",
|
|
159
|
+
start_comments_job: {
|
|
180
160
|
description: "Start a comments export job for one YouTube video.",
|
|
181
161
|
schema: {
|
|
182
162
|
video: z.string().min(1),
|
|
183
163
|
max_comments: z.number().int().min(1).max(1000).optional(),
|
|
184
164
|
order: z.enum(["time", "relevance"]).optional(),
|
|
185
165
|
},
|
|
186
|
-
remotePath: "api/comments/start",
|
|
187
166
|
},
|
|
188
|
-
{
|
|
189
|
-
name: "poll_comments_job",
|
|
167
|
+
poll_comments_job: {
|
|
190
168
|
description: "Check comments export job progress by job_id.",
|
|
191
169
|
schema: {
|
|
192
170
|
job_id: z.string().min(1),
|
|
193
171
|
},
|
|
194
|
-
remotePath: "api/comments/poll",
|
|
195
172
|
},
|
|
196
|
-
{
|
|
197
|
-
name: "resume_comments_job",
|
|
173
|
+
resume_comments_job: {
|
|
198
174
|
description: "Resume an unfinished comments export job.",
|
|
199
175
|
schema: {
|
|
200
176
|
resume_token: z.string().min(1),
|
|
201
177
|
},
|
|
202
|
-
remotePath: "api/comments/resume",
|
|
203
178
|
},
|
|
204
|
-
{
|
|
205
|
-
name: "get_comment_replies",
|
|
179
|
+
get_comment_replies: {
|
|
206
180
|
description: "Get 2nd-level replies for one top-level comment.",
|
|
207
181
|
schema: {
|
|
208
182
|
parent_comment_id: z.string().min(1),
|
|
209
183
|
max_results: z.number().int().min(1).max(1000).optional(),
|
|
210
184
|
page_token: z.string().min(1).optional(),
|
|
211
185
|
},
|
|
212
|
-
remotePath: "api/comments/replies",
|
|
213
186
|
},
|
|
214
|
-
{
|
|
215
|
-
name: "get_quota_usage",
|
|
187
|
+
get_quota_usage: {
|
|
216
188
|
description: "Get YouTube API quota usage for today or a specific date.",
|
|
217
189
|
schema: {
|
|
218
190
|
date: z
|
|
@@ -220,18 +192,27 @@ const REMOTE_TOOLS = [
|
|
|
220
192
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
221
193
|
.optional(),
|
|
222
194
|
},
|
|
223
|
-
remotePath: "api/quota",
|
|
224
195
|
},
|
|
225
|
-
{
|
|
226
|
-
name: "get_patch_notes",
|
|
196
|
+
get_patch_notes: {
|
|
227
197
|
description: "Read server patch notes for latest MCP/API updates.",
|
|
228
198
|
schema: {
|
|
229
199
|
max_chars: z.number().int().min(500).max(50_000).optional(),
|
|
230
200
|
},
|
|
231
|
-
remotePath: "api/patch-notes",
|
|
232
|
-
method: "GET",
|
|
233
201
|
},
|
|
234
|
-
|
|
202
|
+
};
|
|
203
|
+
const REMOTE_TOOLS = YOUTUBE_TOOL_CONTRACTS.map((contract) => {
|
|
204
|
+
const meta = REMOTE_TOOL_META[contract.id];
|
|
205
|
+
if (!meta) {
|
|
206
|
+
throw new Error(`Missing remote tool metadata for contract id: ${contract.id}`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
name: contract.remote_tool_name,
|
|
210
|
+
description: meta.description,
|
|
211
|
+
schema: meta.schema,
|
|
212
|
+
remotePath: contract.http_path.replace(/^\//, ""),
|
|
213
|
+
method: contract.http_method,
|
|
214
|
+
};
|
|
215
|
+
});
|
|
235
216
|
export function registerRemoteTools(server, config, tokenManager) {
|
|
236
217
|
for (const def of REMOTE_TOOLS) {
|
|
237
218
|
createRemoteTool(server, def, tokenManager, config.api_url);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const COOKIE_MISSING_MESSAGE = "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies";
|
|
2
|
+
export declare const COOKIE_EXPIRED_MESSAGE = "YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: yt-mcp setup-cookies";
|
|
3
|
+
export declare const COOKIE_INVALID_MESSAGE = "YouTube rejected the current cookies and auto-refresh failed.\nPlease run in your terminal: yt-mcp setup-cookies";
|
|
4
|
+
export declare const SIGN_IN_REQUIRED_MESSAGE = "YouTube requested an additional sign-in confirmation and auto-refresh failed.\nPlease run in your terminal: yt-mcp setup-cookies";
|
|
5
|
+
export declare const RATE_LIMITED_MESSAGE = "The current YouTube session has been rate-limited. Wait and retry later.";
|
|
6
|
+
export declare const COOKIE_JOB_MESSAGE = "YouTube cookies are missing, expired, or invalid.\nPlease run in your terminal: yt-mcp setup-cookies";
|
|
7
|
+
export type CookieFailureCode = "COOKIES_INVALID" | "COOKIES_EXPIRED" | "SIGN_IN_REQUIRED" | "RATE_LIMITED";
|
|
8
|
+
export declare function tryRefreshSubtitleCookies(): Promise<boolean>;
|
|
9
|
+
export declare function isCookieFailureText(error?: string): boolean;
|
|
10
|
+
export declare function classifyYtDlpCookieFailure(stderr: string): {
|
|
11
|
+
code: CookieFailureCode;
|
|
12
|
+
message: string;
|
|
13
|
+
} | null;
|
|
14
|
+
export declare function toReadableSubtitleJobError(error: unknown): string;
|
|
15
|
+
export declare function ensureSubtitleCookiesReady(): Promise<{
|
|
16
|
+
ok: true;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
code: "COOKIES_MISSING" | "COOKIES_EXPIRED";
|
|
20
|
+
toolMessage: string;
|
|
21
|
+
jobMessage: string;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { PATHS } from "../../utils/config.js";
|
|
2
|
+
import { hasSIDCookies, areCookiesExpired } from "../../utils/cookies.js";
|
|
3
|
+
import { tryHeadlessRefresh } from "../../utils/cookieRefresh.js";
|
|
4
|
+
import { appendDiagnosticLog, classifyYtDlpFailure } from "../../utils/ytdlpFailures.js";
|
|
5
|
+
const COOKIE_SETUP_COMMAND = "yt-mcp setup-cookies";
|
|
6
|
+
export const COOKIE_MISSING_MESSAGE = `No valid YouTube cookies found.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
7
|
+
export const COOKIE_EXPIRED_MESSAGE = `YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
8
|
+
export const COOKIE_INVALID_MESSAGE = `YouTube rejected the current cookies and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
9
|
+
export const SIGN_IN_REQUIRED_MESSAGE = `YouTube requested an additional sign-in confirmation and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
10
|
+
export const RATE_LIMITED_MESSAGE = "The current YouTube session has been rate-limited. Wait and retry later.";
|
|
11
|
+
export const COOKIE_JOB_MESSAGE = `YouTube cookies are missing, expired, or invalid.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
12
|
+
export async function tryRefreshSubtitleCookies() {
|
|
13
|
+
try {
|
|
14
|
+
return await tryHeadlessRefresh();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function isCookieFailureText(error) {
|
|
21
|
+
if (!error) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const normalized = error.toLowerCase();
|
|
25
|
+
return (normalized.includes("cookies_expired") ||
|
|
26
|
+
normalized.includes("cookie") ||
|
|
27
|
+
normalized.includes("sign in") ||
|
|
28
|
+
normalized.includes("login") ||
|
|
29
|
+
normalized.includes("not a bot"));
|
|
30
|
+
}
|
|
31
|
+
export function classifyYtDlpCookieFailure(stderr) {
|
|
32
|
+
const kind = classifyYtDlpFailure(stderr);
|
|
33
|
+
switch (kind) {
|
|
34
|
+
case "COOKIES_EXPIRED":
|
|
35
|
+
return { code: kind, message: COOKIE_EXPIRED_MESSAGE };
|
|
36
|
+
case "COOKIES_INVALID":
|
|
37
|
+
return { code: kind, message: COOKIE_INVALID_MESSAGE };
|
|
38
|
+
case "SIGN_IN_REQUIRED":
|
|
39
|
+
return { code: kind, message: SIGN_IN_REQUIRED_MESSAGE };
|
|
40
|
+
case "RATE_LIMITED":
|
|
41
|
+
return { code: kind, message: RATE_LIMITED_MESSAGE };
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function toReadableSubtitleJobError(error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return isCookieFailureText(message) ? COOKIE_JOB_MESSAGE : message;
|
|
49
|
+
}
|
|
50
|
+
export async function ensureSubtitleCookiesReady() {
|
|
51
|
+
const hasSession = hasSIDCookies(PATHS.cookiesTxt);
|
|
52
|
+
const expired = areCookiesExpired(PATHS.cookiesTxt);
|
|
53
|
+
if (hasSession && !expired) {
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
57
|
+
if (refreshed && hasSIDCookies(PATHS.cookiesTxt) && !areCookiesExpired(PATHS.cookiesTxt)) {
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
code: hasSession && expired ? "COOKIES_EXPIRED" : "COOKIES_MISSING",
|
|
63
|
+
toolMessage: hasSession && expired ? COOKIE_EXPIRED_MESSAGE : COOKIE_MISSING_MESSAGE,
|
|
64
|
+
jobMessage: appendDiagnosticLog(COOKIE_JOB_MESSAGE, undefined),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, RATE_LIMITED_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, type CookieFailureCode } from "./cookieSession.js";
|
|
2
|
+
export declare function downloadSubtitle(videoId: string, lang: string, format: string, options?: {
|
|
3
|
+
sourceUrl?: string;
|
|
4
|
+
outputDir?: string;
|
|
5
|
+
outputStem?: string;
|
|
6
|
+
targetFile?: string;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
filePath?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
cookieFailureCode?: CookieFailureCode;
|
|
13
|
+
diagnosticLogPath?: string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function downloadSubtitlesForLanguages(input: {
|
|
16
|
+
videoId: string;
|
|
17
|
+
sourceUrl?: string;
|
|
18
|
+
languages: string[];
|
|
19
|
+
formats: string[];
|
|
20
|
+
subtitlesDir: string;
|
|
21
|
+
skipMissingLanguages?: boolean;
|
|
22
|
+
onProgress?: (completed: number, total: number) => void;
|
|
23
|
+
}): Promise<Record<string, string[]>>;
|
|
24
|
+
export { COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, RATE_LIMITED_MESSAGE };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { PATHS } from "../../utils/config.js";
|
|
4
|
+
import { runYtDlp } from "../../utils/ytdlp.js";
|
|
5
|
+
import { appendDiagnosticLog } from "../../utils/ytdlpFailures.js";
|
|
6
|
+
import { classifyYtDlpCookieFailure, COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, COOKIE_JOB_MESSAGE, RATE_LIMITED_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, ensureSubtitleCookiesReady, isCookieFailureText, tryRefreshSubtitleCookies } from "./cookieSession.js";
|
|
7
|
+
import { vttToCsv } from "./parse.js";
|
|
8
|
+
function todayDateStr() {
|
|
9
|
+
const d = new Date();
|
|
10
|
+
const yyyy = d.getFullYear();
|
|
11
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
12
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
13
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
14
|
+
}
|
|
15
|
+
export async function downloadSubtitle(videoId, lang, format, options = {}) {
|
|
16
|
+
const outputDir = options.outputDir ?? PATHS.subtitlesDir;
|
|
17
|
+
mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
if (options.targetFile) {
|
|
19
|
+
mkdirSync(dirname(options.targetFile), { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
const outTemplate = join(outputDir, options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`);
|
|
22
|
+
const dlFormat = format === "csv" ? "vtt" : format;
|
|
23
|
+
const result = await runYtDlp([
|
|
24
|
+
"--skip-download",
|
|
25
|
+
"-f", "mhtml",
|
|
26
|
+
"--write-sub",
|
|
27
|
+
"--write-auto-sub",
|
|
28
|
+
"--sub-langs",
|
|
29
|
+
lang,
|
|
30
|
+
"--sub-format",
|
|
31
|
+
dlFormat,
|
|
32
|
+
"--output",
|
|
33
|
+
outTemplate,
|
|
34
|
+
options.sourceUrl ?? `https://www.youtube.com/watch?v=${videoId}`,
|
|
35
|
+
]);
|
|
36
|
+
const cookieFailure = result.exitCode !== 0 ? classifyYtDlpCookieFailure(result.stderr) : null;
|
|
37
|
+
if (cookieFailure) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
cookieFailureCode: cookieFailure.code,
|
|
41
|
+
diagnosticLogPath: result.failureLogPath,
|
|
42
|
+
error: appendDiagnosticLog(cookieFailure.message, result.failureLogPath),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (result.exitCode !== 0) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
diagnosticLogPath: result.failureLogPath,
|
|
49
|
+
error: appendDiagnosticLog(result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`, result.failureLogPath),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const searchFormat = format === "csv" ? "vtt" : format;
|
|
53
|
+
const possibleExts = [`${lang}.${searchFormat}`, `${lang}.vtt`, `${lang}.srt`, `${lang}.ttml`, `${lang}.srv3`];
|
|
54
|
+
let foundFile;
|
|
55
|
+
for (const ext of possibleExts) {
|
|
56
|
+
const candidate = `${outTemplate}.${ext}`;
|
|
57
|
+
if (existsSync(candidate)) {
|
|
58
|
+
foundFile = candidate;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!foundFile) {
|
|
63
|
+
try {
|
|
64
|
+
const files = readdirSync(outputDir);
|
|
65
|
+
const prefix = options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`;
|
|
66
|
+
const match = files.find((f) => f.startsWith(prefix));
|
|
67
|
+
if (match) {
|
|
68
|
+
foundFile = join(outputDir, match);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!foundFile) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: `No subtitle file found for language '${lang}'`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (format === "csv") {
|
|
82
|
+
const vttPath = foundFile;
|
|
83
|
+
const vttContent = readFileSync(foundFile, "utf8");
|
|
84
|
+
const csvContent = vttToCsv(vttContent);
|
|
85
|
+
const csvPath = foundFile.replace(/\.vtt$/, ".csv");
|
|
86
|
+
writeFileSync(csvPath, csvContent, "utf8");
|
|
87
|
+
foundFile = csvPath;
|
|
88
|
+
if (vttPath !== csvPath) {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(vttPath);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// ignore
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (options.targetFile && foundFile !== options.targetFile) {
|
|
98
|
+
renameSync(foundFile, options.targetFile);
|
|
99
|
+
foundFile = options.targetFile;
|
|
100
|
+
}
|
|
101
|
+
const stat = statSync(foundFile);
|
|
102
|
+
if (stat.size <= 100 * 1024) {
|
|
103
|
+
const text = readFileSync(foundFile, "utf8");
|
|
104
|
+
return { ok: true, text, filePath: foundFile };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, filePath: foundFile };
|
|
107
|
+
}
|
|
108
|
+
function isUnavailableSubtitleError(error) {
|
|
109
|
+
if (!error) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const normalized = error.toLowerCase();
|
|
113
|
+
return (normalized.includes("no subtitle file found") ||
|
|
114
|
+
normalized.includes("no subtitles") ||
|
|
115
|
+
normalized.includes("subtitle is not available") ||
|
|
116
|
+
normalized.includes("requested subtitles are not available"));
|
|
117
|
+
}
|
|
118
|
+
export async function downloadSubtitlesForLanguages(input) {
|
|
119
|
+
const cookieCheck = await ensureSubtitleCookiesReady();
|
|
120
|
+
if (!cookieCheck.ok) {
|
|
121
|
+
throw new Error(cookieCheck.jobMessage);
|
|
122
|
+
}
|
|
123
|
+
const filesByFormat = {};
|
|
124
|
+
const total = input.formats.length * input.languages.length;
|
|
125
|
+
let completed = 0;
|
|
126
|
+
for (const format of input.formats) {
|
|
127
|
+
filesByFormat[format] = [];
|
|
128
|
+
for (const lang of input.languages) {
|
|
129
|
+
const targetFile = join(input.subtitlesDir, `${lang}.${format}`);
|
|
130
|
+
let result = await downloadSubtitle(input.videoId, lang, format, {
|
|
131
|
+
sourceUrl: input.sourceUrl,
|
|
132
|
+
outputDir: input.subtitlesDir,
|
|
133
|
+
outputStem: `${lang}`,
|
|
134
|
+
targetFile,
|
|
135
|
+
});
|
|
136
|
+
if (result.cookieFailureCode && result.cookieFailureCode !== "RATE_LIMITED") {
|
|
137
|
+
const refreshed = await tryRefreshSubtitleCookies();
|
|
138
|
+
if (refreshed) {
|
|
139
|
+
result = await downloadSubtitle(input.videoId, lang, format, {
|
|
140
|
+
sourceUrl: input.sourceUrl,
|
|
141
|
+
outputDir: input.subtitlesDir,
|
|
142
|
+
outputStem: `${lang}`,
|
|
143
|
+
targetFile,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!result.ok || !result.filePath) {
|
|
148
|
+
if (isCookieFailureText(result.error)) {
|
|
149
|
+
throw new Error(COOKIE_JOB_MESSAGE);
|
|
150
|
+
}
|
|
151
|
+
if (input.skipMissingLanguages && isUnavailableSubtitleError(result.error)) {
|
|
152
|
+
completed += 1;
|
|
153
|
+
input.onProgress?.(completed, total);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
throw new Error(result.error ?? `Failed to download ${format} subtitle for ${lang}`);
|
|
157
|
+
}
|
|
158
|
+
filesByFormat[format].push(result.filePath);
|
|
159
|
+
completed += 1;
|
|
160
|
+
input.onProgress?.(completed, total);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const hasAnySubtitleFiles = Object.values(filesByFormat).some((files) => files.length > 0);
|
|
164
|
+
if (!hasAnySubtitleFiles) {
|
|
165
|
+
throw new Error(`No subtitles found for requested languages: ${input.languages.join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
return filesByFormat;
|
|
168
|
+
}
|
|
169
|
+
export { COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, RATE_LIMITED_MESSAGE };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function vttToCsv(vtt: string): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function decodeHtmlEntities(text) {
|
|
2
|
+
return text
|
|
3
|
+
.replace(/>/g, ">")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, "'")
|
|
8
|
+
.replace(/ /g, " ");
|
|
9
|
+
}
|
|
10
|
+
function parseTimestamp(line) {
|
|
11
|
+
const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
const toSec = (t) => {
|
|
15
|
+
const parts = t.split(":");
|
|
16
|
+
return Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]);
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
startStr: match[1],
|
|
20
|
+
endStr: match[2],
|
|
21
|
+
startSec: toSec(match[1]),
|
|
22
|
+
endSec: toSec(match[2]),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function csvEscapeField(value) {
|
|
26
|
+
if (/[",\n\r]/.test(value)) {
|
|
27
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function vttToCsv(vtt) {
|
|
32
|
+
const lines = vtt.split("\n");
|
|
33
|
+
const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
|
|
34
|
+
const rawCues = [];
|
|
35
|
+
let currentTs = null;
|
|
36
|
+
let currentTextLines = [];
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (trimmed.includes(" --> ")) {
|
|
40
|
+
if (currentTs && currentTextLines.length > 0) {
|
|
41
|
+
let text;
|
|
42
|
+
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
43
|
+
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
44
|
+
.replace(/<[^>]*>/g, "")
|
|
45
|
+
.trim());
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
text = decodeHtmlEntities(currentTextLines
|
|
49
|
+
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join(" "));
|
|
52
|
+
}
|
|
53
|
+
if (text) {
|
|
54
|
+
rawCues.push({ ...currentTs, text });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
currentTs = parseTimestamp(trimmed);
|
|
58
|
+
currentTextLines = [];
|
|
59
|
+
}
|
|
60
|
+
else if (trimmed &&
|
|
61
|
+
!trimmed.startsWith("WEBVTT") &&
|
|
62
|
+
!trimmed.startsWith("Kind:") &&
|
|
63
|
+
!trimmed.startsWith("Language:") &&
|
|
64
|
+
!/^\d+$/.test(trimmed)) {
|
|
65
|
+
currentTextLines.push(trimmed);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (currentTs && currentTextLines.length > 0) {
|
|
69
|
+
let text;
|
|
70
|
+
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
71
|
+
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
72
|
+
.replace(/<[^>]*>/g, "")
|
|
73
|
+
.trim());
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
text = decodeHtmlEntities(currentTextLines
|
|
77
|
+
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(" "));
|
|
80
|
+
}
|
|
81
|
+
if (text) {
|
|
82
|
+
rawCues.push({ ...currentTs, text });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (rawCues.length === 0) {
|
|
86
|
+
return "start_time,end_time,text\n";
|
|
87
|
+
}
|
|
88
|
+
const deduped = [];
|
|
89
|
+
for (let i = 0; i < rawCues.length; i++) {
|
|
90
|
+
const cur = rawCues[i];
|
|
91
|
+
const duration = cur.endSec - cur.startSec;
|
|
92
|
+
if (duration < 0.05)
|
|
93
|
+
continue;
|
|
94
|
+
if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
|
|
95
|
+
deduped[deduped.length - 1].endSec = cur.endSec;
|
|
96
|
+
deduped[deduped.length - 1].endStr = cur.endStr;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
deduped.push({ ...cur });
|
|
100
|
+
}
|
|
101
|
+
const csvRows = ["start_time,end_time,text"];
|
|
102
|
+
for (const cue of deduped) {
|
|
103
|
+
csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
|
|
104
|
+
}
|
|
105
|
+
return csvRows.join("\n") + "\n";
|
|
106
|
+
}
|
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TokenManager } from "@mkterswingman/5mghost-shared-client/auth";
|
|
2
3
|
import type { YtMcpConfig } from "../utils/config.js";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
import { toReadableSubtitleJobError } from "./subtitles/cookieSession.js";
|
|
5
|
+
import { downloadSubtitlesForLanguages } from "./subtitles/download.js";
|
|
6
|
+
import { vttToCsv } from "./subtitles/parse.js";
|
|
7
|
+
export { vttToCsv, downloadSubtitlesForLanguages, toReadableSubtitleJobError };
|
|
29
8
|
export declare function registerSubtitleTools(server: McpServer, config: YtMcpConfig, tokenManager: TokenManager): void;
|