@rv404/mcp-launchpad 1.0.0
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/.env.example +29 -0
- package/README.md +248 -0
- package/dist/backends/clickup.d.ts +3 -0
- package/dist/backends/clickup.d.ts.map +1 -0
- package/dist/backends/clickup.js +1550 -0
- package/dist/backends/clickup.js.map +1 -0
- package/dist/backends/email.d.ts +9 -0
- package/dist/backends/email.d.ts.map +1 -0
- package/dist/backends/email.js +452 -0
- package/dist/backends/email.js.map +1 -0
- package/dist/backends/firecrawl.d.ts +3 -0
- package/dist/backends/firecrawl.d.ts.map +1 -0
- package/dist/backends/firecrawl.js +297 -0
- package/dist/backends/firecrawl.js.map +1 -0
- package/dist/backends/gemini.d.ts +3 -0
- package/dist/backends/gemini.d.ts.map +1 -0
- package/dist/backends/gemini.js +702 -0
- package/dist/backends/gemini.js.map +1 -0
- package/dist/backends/github.d.ts +3 -0
- package/dist/backends/github.d.ts.map +1 -0
- package/dist/backends/github.js +2129 -0
- package/dist/backends/github.js.map +1 -0
- package/dist/backends/index.d.ts +2 -0
- package/dist/backends/index.d.ts.map +1 -0
- package/dist/backends/index.js +27 -0
- package/dist/backends/index.js.map +1 -0
- package/dist/backends/kie.d.ts +3 -0
- package/dist/backends/kie.d.ts.map +1 -0
- package/dist/backends/kie.js +399 -0
- package/dist/backends/kie.js.map +1 -0
- package/dist/backends/n8n.d.ts +3 -0
- package/dist/backends/n8n.d.ts.map +1 -0
- package/dist/backends/n8n.js +414 -0
- package/dist/backends/n8n.js.map +1 -0
- package/dist/backends/notion.d.ts +3 -0
- package/dist/backends/notion.d.ts.map +1 -0
- package/dist/backends/notion.js +375 -0
- package/dist/backends/notion.js.map +1 -0
- package/dist/backends/quickbooks.d.ts +3 -0
- package/dist/backends/quickbooks.d.ts.map +1 -0
- package/dist/backends/quickbooks.js +2183 -0
- package/dist/backends/quickbooks.js.map +1 -0
- package/dist/backends/slack.d.ts +3 -0
- package/dist/backends/slack.d.ts.map +1 -0
- package/dist/backends/slack.js +1725 -0
- package/dist/backends/slack.js.map +1 -0
- package/dist/backends/youtube.d.ts +3 -0
- package/dist/backends/youtube.d.ts.map +1 -0
- package/dist/backends/youtube.js +936 -0
- package/dist/backends/youtube.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +150 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +36 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +283 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +65 -0
- package/dist/types.js.map +1 -0
- package/lib/mcp-file-output/dist/index.d.ts +99 -0
- package/lib/mcp-file-output/dist/index.d.ts.map +1 -0
- package/lib/mcp-file-output/dist/index.js +215 -0
- package/lib/mcp-file-output/dist/index.js.map +1 -0
- package/lib/mcp-file-output/node_modules/@types/node/LICENSE +21 -0
- package/lib/mcp-file-output/node_modules/@types/node/README.md +15 -0
- package/lib/mcp-file-output/node_modules/@types/node/assert/strict.d.ts +8 -0
- package/lib/mcp-file-output/node_modules/@types/node/assert.d.ts +1062 -0
- package/lib/mcp-file-output/node_modules/@types/node/async_hooks.d.ts +605 -0
- package/lib/mcp-file-output/node_modules/@types/node/buffer.buffer.d.ts +471 -0
- package/lib/mcp-file-output/node_modules/@types/node/buffer.d.ts +1936 -0
- package/lib/mcp-file-output/node_modules/@types/node/child_process.d.ts +1475 -0
- package/lib/mcp-file-output/node_modules/@types/node/cluster.d.ts +577 -0
- package/lib/mcp-file-output/node_modules/@types/node/compatibility/disposable.d.ts +16 -0
- package/lib/mcp-file-output/node_modules/@types/node/compatibility/index.d.ts +9 -0
- package/lib/mcp-file-output/node_modules/@types/node/compatibility/indexable.d.ts +20 -0
- package/lib/mcp-file-output/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
- package/lib/mcp-file-output/node_modules/@types/node/console.d.ts +452 -0
- package/lib/mcp-file-output/node_modules/@types/node/constants.d.ts +21 -0
- package/lib/mcp-file-output/node_modules/@types/node/crypto.d.ts +4590 -0
- package/lib/mcp-file-output/node_modules/@types/node/dgram.d.ts +597 -0
- package/lib/mcp-file-output/node_modules/@types/node/diagnostics_channel.d.ts +578 -0
- package/lib/mcp-file-output/node_modules/@types/node/dns/promises.d.ts +479 -0
- package/lib/mcp-file-output/node_modules/@types/node/dns.d.ts +871 -0
- package/lib/mcp-file-output/node_modules/@types/node/domain.d.ts +170 -0
- package/lib/mcp-file-output/node_modules/@types/node/events.d.ts +977 -0
- package/lib/mcp-file-output/node_modules/@types/node/fs/promises.d.ts +1270 -0
- package/lib/mcp-file-output/node_modules/@types/node/fs.d.ts +4375 -0
- package/lib/mcp-file-output/node_modules/@types/node/globals.d.ts +172 -0
- package/lib/mcp-file-output/node_modules/@types/node/globals.typedarray.d.ts +38 -0
- package/lib/mcp-file-output/node_modules/@types/node/http.d.ts +2049 -0
- package/lib/mcp-file-output/node_modules/@types/node/http2.d.ts +2631 -0
- package/lib/mcp-file-output/node_modules/@types/node/https.d.ts +578 -0
- package/lib/mcp-file-output/node_modules/@types/node/index.d.ts +93 -0
- package/lib/mcp-file-output/node_modules/@types/node/inspector.generated.d.ts +3966 -0
- package/lib/mcp-file-output/node_modules/@types/node/module.d.ts +539 -0
- package/lib/mcp-file-output/node_modules/@types/node/net.d.ts +1012 -0
- package/lib/mcp-file-output/node_modules/@types/node/os.d.ts +506 -0
- package/lib/mcp-file-output/node_modules/@types/node/package.json +140 -0
- package/lib/mcp-file-output/node_modules/@types/node/path.d.ts +200 -0
- package/lib/mcp-file-output/node_modules/@types/node/perf_hooks.d.ts +961 -0
- package/lib/mcp-file-output/node_modules/@types/node/process.d.ts +1957 -0
- package/lib/mcp-file-output/node_modules/@types/node/punycode.d.ts +117 -0
- package/lib/mcp-file-output/node_modules/@types/node/querystring.d.ts +152 -0
- package/lib/mcp-file-output/node_modules/@types/node/readline/promises.d.ts +162 -0
- package/lib/mcp-file-output/node_modules/@types/node/readline.d.ts +589 -0
- package/lib/mcp-file-output/node_modules/@types/node/repl.d.ts +430 -0
- package/lib/mcp-file-output/node_modules/@types/node/sea.d.ts +153 -0
- package/lib/mcp-file-output/node_modules/@types/node/stream/consumers.d.ts +38 -0
- package/lib/mcp-file-output/node_modules/@types/node/stream/promises.d.ts +90 -0
- package/lib/mcp-file-output/node_modules/@types/node/stream/web.d.ts +533 -0
- package/lib/mcp-file-output/node_modules/@types/node/stream.d.ts +1675 -0
- package/lib/mcp-file-output/node_modules/@types/node/string_decoder.d.ts +67 -0
- package/lib/mcp-file-output/node_modules/@types/node/test.d.ts +1787 -0
- package/lib/mcp-file-output/node_modules/@types/node/timers/promises.d.ts +108 -0
- package/lib/mcp-file-output/node_modules/@types/node/timers.d.ts +286 -0
- package/lib/mcp-file-output/node_modules/@types/node/tls.d.ts +1255 -0
- package/lib/mcp-file-output/node_modules/@types/node/trace_events.d.ts +197 -0
- package/lib/mcp-file-output/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +468 -0
- package/lib/mcp-file-output/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +34 -0
- package/lib/mcp-file-output/node_modules/@types/node/ts5.6/index.d.ts +93 -0
- package/lib/mcp-file-output/node_modules/@types/node/tty.d.ts +208 -0
- package/lib/mcp-file-output/node_modules/@types/node/url.d.ts +964 -0
- package/lib/mcp-file-output/node_modules/@types/node/util.d.ts +2331 -0
- package/lib/mcp-file-output/node_modules/@types/node/v8.d.ts +809 -0
- package/lib/mcp-file-output/node_modules/@types/node/vm.d.ts +1001 -0
- package/lib/mcp-file-output/node_modules/@types/node/wasi.d.ts +181 -0
- package/lib/mcp-file-output/node_modules/@types/node/web-globals/abortcontroller.d.ts +34 -0
- package/lib/mcp-file-output/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
- package/lib/mcp-file-output/node_modules/@types/node/web-globals/events.d.ts +97 -0
- package/lib/mcp-file-output/node_modules/@types/node/web-globals/fetch.d.ts +46 -0
- package/lib/mcp-file-output/node_modules/@types/node/worker_threads.d.ts +715 -0
- package/lib/mcp-file-output/node_modules/@types/node/zlib.d.ts +540 -0
- package/lib/mcp-file-output/package.json +19 -0
- package/lib/mcp-file-output/src/index.ts +309 -0
- package/lib/mcp-file-output/tsconfig.json +20 -0
- package/package.json +64 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Configuration
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// YouTube Data API v3 (optional - for metadata)
|
|
10
|
+
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY;
|
|
11
|
+
// Gemini API for video analysis (reuses existing env var)
|
|
12
|
+
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY;
|
|
13
|
+
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
|
|
14
|
+
// Output directory for downloads - uses env var or user home directory
|
|
15
|
+
const getDefaultDataDir = () => {
|
|
16
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '.';
|
|
17
|
+
return path.join(homeDir, '.router-mcp', 'data');
|
|
18
|
+
};
|
|
19
|
+
const DATA_DIR = process.env.ROUTER_MCP_DATA_DIR || process.env.MCP_FILE_OUTPUT_DIR || getDefaultDataDir();
|
|
20
|
+
const YOUTUBE_DIR = path.join(DATA_DIR, 'youtube');
|
|
21
|
+
const DOWNLOADS_DIR = path.join(YOUTUBE_DIR, 'downloads');
|
|
22
|
+
const TRANSCRIPTS_DIR = path.join(YOUTUBE_DIR, 'transcripts');
|
|
23
|
+
const ANALYSES_DIR = path.join(YOUTUBE_DIR, 'analyses');
|
|
24
|
+
// Ensure directories exist
|
|
25
|
+
function ensureDirectories() {
|
|
26
|
+
for (const dir of [YOUTUBE_DIR, DOWNLOADS_DIR, TRANSCRIPTS_DIR, ANALYSES_DIR]) {
|
|
27
|
+
if (!fs.existsSync(dir)) {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Action Definitions
|
|
34
|
+
// ============================================================================
|
|
35
|
+
const actions = [
|
|
36
|
+
// Metadata
|
|
37
|
+
{
|
|
38
|
+
name: 'get_metadata',
|
|
39
|
+
description: 'Get video metadata: title, channel, duration, description, view count, publish date, thumbnails.',
|
|
40
|
+
params: [
|
|
41
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
// Transcript
|
|
45
|
+
{
|
|
46
|
+
name: 'get_transcript',
|
|
47
|
+
description: 'Get video transcript/captions with timestamps. Supports multiple languages.',
|
|
48
|
+
params: [
|
|
49
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
50
|
+
{ name: 'language', type: 'string', required: false, description: 'Language code (e.g., "en", "es", "auto"). Default: "en"' },
|
|
51
|
+
{ name: 'format', type: 'string', required: false, description: 'Output format: "text" (plain text), "timestamped" (with timestamps), "srt" (SubRip format). Default: "timestamped"' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
// Chapters
|
|
55
|
+
{
|
|
56
|
+
name: 'get_chapters',
|
|
57
|
+
description: 'Get video chapters if available (from description timestamps).',
|
|
58
|
+
params: [
|
|
59
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
// Downloads
|
|
63
|
+
{
|
|
64
|
+
name: 'download_audio',
|
|
65
|
+
description: 'Download audio from a YouTube video (MP3 format). Requires yt-dlp installed.',
|
|
66
|
+
params: [
|
|
67
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
68
|
+
{ name: 'outputPath', type: 'string', required: false, description: 'Custom output path. Default: auto-generated in youtube/downloads/' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'download_video',
|
|
73
|
+
description: 'Download video from YouTube. Requires yt-dlp installed.',
|
|
74
|
+
params: [
|
|
75
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
76
|
+
{ name: 'quality', type: 'string', required: false, description: 'Video quality: "best", "1080p", "720p", "480p", "worst". Default: "720p"' },
|
|
77
|
+
{ name: 'outputPath', type: 'string', required: false, description: 'Custom output path. Default: auto-generated in youtube/downloads/' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
// Analysis
|
|
81
|
+
{
|
|
82
|
+
name: 'summarize',
|
|
83
|
+
description: 'Get a concise summary of the video based on its transcript.',
|
|
84
|
+
params: [
|
|
85
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
86
|
+
{ name: 'style', type: 'string', required: false, description: 'Summary style: "brief" (1-2 paragraphs), "detailed" (section by section), "bullets" (key points). Default: "brief"' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'analyze',
|
|
91
|
+
description: 'Full hybrid analysis: transcript analysis + optional Gemini visual analysis for comprehensive understanding.',
|
|
92
|
+
params: [
|
|
93
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
94
|
+
{ name: 'mode', type: 'string', required: false, description: 'Analysis mode: "transcript" (fast, text-only), "visual" (Gemini video analysis), "full" (both combined). Default: "transcript"' },
|
|
95
|
+
{ name: 'questions', type: 'array', required: false, description: 'Specific questions to answer about the video content' },
|
|
96
|
+
{ name: 'extractCode', type: 'boolean', required: false, description: 'Extract code snippets from programming videos. Default: false' },
|
|
97
|
+
{ name: 'outputPath', type: 'string', required: false, description: 'Save analysis to file. Default: returns in response' },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'extract_code',
|
|
102
|
+
description: 'Extract code snippets mentioned or shown in programming tutorial videos.',
|
|
103
|
+
params: [
|
|
104
|
+
{ name: 'url', type: 'string', required: true, description: 'YouTube video URL or video ID' },
|
|
105
|
+
{ name: 'language', type: 'string', required: false, description: 'Programming language to focus on (e.g., "python", "javascript"). Default: auto-detect' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Utility Functions
|
|
111
|
+
// ============================================================================
|
|
112
|
+
function extractVideoId(urlOrId) {
|
|
113
|
+
// Already a video ID (11 characters)
|
|
114
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(urlOrId)) {
|
|
115
|
+
return urlOrId;
|
|
116
|
+
}
|
|
117
|
+
// Various YouTube URL formats
|
|
118
|
+
const patterns = [
|
|
119
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
|
120
|
+
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
|
|
121
|
+
];
|
|
122
|
+
for (const pattern of patterns) {
|
|
123
|
+
const match = urlOrId.match(pattern);
|
|
124
|
+
if (match) {
|
|
125
|
+
return match[1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`Invalid YouTube URL or video ID: ${urlOrId}`);
|
|
129
|
+
}
|
|
130
|
+
function formatDuration(seconds) {
|
|
131
|
+
const hours = Math.floor(seconds / 3600);
|
|
132
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
133
|
+
const secs = Math.floor(seconds % 60);
|
|
134
|
+
if (hours > 0) {
|
|
135
|
+
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
136
|
+
}
|
|
137
|
+
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
138
|
+
}
|
|
139
|
+
function formatTimestamp(seconds) {
|
|
140
|
+
const hours = Math.floor(seconds / 3600);
|
|
141
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
142
|
+
const secs = Math.floor(seconds % 60);
|
|
143
|
+
const ms = Math.floor((seconds % 1) * 1000);
|
|
144
|
+
if (hours > 0) {
|
|
145
|
+
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
|
146
|
+
}
|
|
147
|
+
return `00:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
|
148
|
+
}
|
|
149
|
+
// yt-dlp executable - should be installed and available on PATH
|
|
150
|
+
// Install with: pip install yt-dlp
|
|
151
|
+
const YT_DLP_PATHS = [
|
|
152
|
+
'yt-dlp', // Standard: on PATH (recommended)
|
|
153
|
+
'yt-dlp.exe', // Windows: on PATH
|
|
154
|
+
];
|
|
155
|
+
let ytDlpPath = null;
|
|
156
|
+
async function checkYtDlp() {
|
|
157
|
+
// Return cached path if already found
|
|
158
|
+
if (ytDlpPath)
|
|
159
|
+
return true;
|
|
160
|
+
for (const ytdlp of YT_DLP_PATHS) {
|
|
161
|
+
try {
|
|
162
|
+
await execAsync(`"${ytdlp}" --version`);
|
|
163
|
+
ytDlpPath = ytdlp;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
function getYtDlpCommand() {
|
|
173
|
+
return ytDlpPath ? `"${ytDlpPath}"` : 'yt-dlp';
|
|
174
|
+
}
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Get Metadata (using yt-dlp for reliability)
|
|
177
|
+
// ============================================================================
|
|
178
|
+
async function getMetadata(params) {
|
|
179
|
+
const { url } = params;
|
|
180
|
+
if (!url || typeof url !== 'string') {
|
|
181
|
+
throw new Error('url is required');
|
|
182
|
+
}
|
|
183
|
+
const videoId = extractVideoId(url);
|
|
184
|
+
// Try YouTube Data API first if available
|
|
185
|
+
if (YOUTUBE_API_KEY) {
|
|
186
|
+
try {
|
|
187
|
+
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails,statistics&id=${videoId}&key=${YOUTUBE_API_KEY}`;
|
|
188
|
+
const response = await fetch(apiUrl);
|
|
189
|
+
if (response.ok) {
|
|
190
|
+
const data = await response.json();
|
|
191
|
+
if (data.items && data.items.length > 0) {
|
|
192
|
+
const item = data.items[0];
|
|
193
|
+
const snippet = item.snippet || {};
|
|
194
|
+
const contentDetails = item.contentDetails || {};
|
|
195
|
+
const statistics = item.statistics || {};
|
|
196
|
+
// Parse ISO 8601 duration
|
|
197
|
+
let durationSeconds = 0;
|
|
198
|
+
const durationMatch = contentDetails.duration?.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
|
199
|
+
if (durationMatch) {
|
|
200
|
+
durationSeconds = (parseInt(durationMatch[1] || '0') * 3600) +
|
|
201
|
+
(parseInt(durationMatch[2] || '0') * 60) +
|
|
202
|
+
parseInt(durationMatch[3] || '0');
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
success: true,
|
|
206
|
+
videoId,
|
|
207
|
+
title: snippet.title,
|
|
208
|
+
channel: snippet.channelTitle,
|
|
209
|
+
description: snippet.description,
|
|
210
|
+
publishedAt: snippet.publishedAt,
|
|
211
|
+
duration: formatDuration(durationSeconds),
|
|
212
|
+
durationSeconds,
|
|
213
|
+
viewCount: statistics.viewCount ? parseInt(statistics.viewCount) : null,
|
|
214
|
+
likeCount: statistics.likeCount ? parseInt(statistics.likeCount) : null,
|
|
215
|
+
commentCount: statistics.commentCount ? parseInt(statistics.commentCount) : null,
|
|
216
|
+
tags: snippet.tags || [],
|
|
217
|
+
thumbnails: snippet.thumbnails,
|
|
218
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Fall through to yt-dlp
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Fallback to yt-dlp
|
|
228
|
+
if (!(await checkYtDlp())) {
|
|
229
|
+
throw new Error('yt-dlp is not installed. Install it with: pip install yt-dlp');
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const { stdout } = await execAsync(`${getYtDlpCommand()} --dump-json --no-download "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 10 * 1024 * 1024 });
|
|
233
|
+
const info = JSON.parse(stdout);
|
|
234
|
+
return {
|
|
235
|
+
success: true,
|
|
236
|
+
videoId,
|
|
237
|
+
title: info.title,
|
|
238
|
+
channel: info.channel || info.uploader,
|
|
239
|
+
description: info.description,
|
|
240
|
+
publishedAt: info.upload_date ? `${info.upload_date.slice(0, 4)}-${info.upload_date.slice(4, 6)}-${info.upload_date.slice(6, 8)}` : null,
|
|
241
|
+
duration: info.duration ? formatDuration(info.duration) : null,
|
|
242
|
+
durationSeconds: info.duration,
|
|
243
|
+
viewCount: info.view_count,
|
|
244
|
+
likeCount: info.like_count,
|
|
245
|
+
commentCount: info.comment_count,
|
|
246
|
+
tags: info.tags || [],
|
|
247
|
+
thumbnail: info.thumbnail,
|
|
248
|
+
hasChapters: !!(info.chapters && info.chapters.length > 0),
|
|
249
|
+
chapterCount: info.chapters?.length || 0,
|
|
250
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
throw new Error(`Failed to get metadata: ${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Get Transcript
|
|
259
|
+
// ============================================================================
|
|
260
|
+
async function getTranscript(params) {
|
|
261
|
+
const { url, language = 'en', format = 'timestamped' } = params;
|
|
262
|
+
if (!url || typeof url !== 'string') {
|
|
263
|
+
throw new Error('url is required');
|
|
264
|
+
}
|
|
265
|
+
const videoId = extractVideoId(url);
|
|
266
|
+
if (!(await checkYtDlp())) {
|
|
267
|
+
throw new Error('yt-dlp is not installed. Install it with: pip install yt-dlp');
|
|
268
|
+
}
|
|
269
|
+
ensureDirectories();
|
|
270
|
+
// Download subtitles using yt-dlp
|
|
271
|
+
const tempDir = path.join(TRANSCRIPTS_DIR, `temp_${videoId}_${Date.now()}`);
|
|
272
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
273
|
+
try {
|
|
274
|
+
// Try to get subtitles (auto-generated or manual)
|
|
275
|
+
const langArg = language === 'auto' ? '--sub-langs "en.*,.*"' : `--sub-langs "${language}.*,en.*"`;
|
|
276
|
+
await execAsync(`${getYtDlpCommand()} --write-auto-sub --write-sub ${langArg} --sub-format json3 --skip-download -o "${path.join(tempDir, '%(id)s')}" "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 10 * 1024 * 1024 });
|
|
277
|
+
// Find the downloaded subtitle file
|
|
278
|
+
const files = fs.readdirSync(tempDir);
|
|
279
|
+
const subFile = files.find(f => f.endsWith('.json3'));
|
|
280
|
+
if (!subFile) {
|
|
281
|
+
// Try VTT format as fallback
|
|
282
|
+
await execAsync(`${getYtDlpCommand()} --write-auto-sub --write-sub ${langArg} --sub-format vtt --skip-download -o "${path.join(tempDir, '%(id)s')}" "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 10 * 1024 * 1024 });
|
|
283
|
+
const vttFiles = fs.readdirSync(tempDir);
|
|
284
|
+
const vttFile = vttFiles.find(f => f.endsWith('.vtt'));
|
|
285
|
+
if (!vttFile) {
|
|
286
|
+
throw new Error('No captions available for this video');
|
|
287
|
+
}
|
|
288
|
+
// Parse VTT file
|
|
289
|
+
const vttContent = fs.readFileSync(path.join(tempDir, vttFile), 'utf-8');
|
|
290
|
+
const segments = parseVTT(vttContent);
|
|
291
|
+
return formatTranscriptOutput(videoId, segments, format);
|
|
292
|
+
}
|
|
293
|
+
// Parse JSON3 format
|
|
294
|
+
const json3Content = fs.readFileSync(path.join(tempDir, subFile), 'utf-8');
|
|
295
|
+
const json3Data = JSON.parse(json3Content);
|
|
296
|
+
const segments = [];
|
|
297
|
+
if (json3Data.events) {
|
|
298
|
+
for (const event of json3Data.events) {
|
|
299
|
+
if (event.segs && event.tStartMs !== undefined) {
|
|
300
|
+
const text = event.segs.map(s => s.utf8 || '').join('').trim();
|
|
301
|
+
if (text && !text.startsWith('[')) { // Skip [Music] etc
|
|
302
|
+
segments.push({
|
|
303
|
+
start: event.tStartMs / 1000,
|
|
304
|
+
duration: (event.dDurationMs || 0) / 1000,
|
|
305
|
+
text,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return formatTranscriptOutput(videoId, segments, format);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
// Cleanup temp directory
|
|
315
|
+
try {
|
|
316
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Ignore cleanup errors
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function parseVTT(content) {
|
|
324
|
+
const segments = [];
|
|
325
|
+
const lines = content.split('\n');
|
|
326
|
+
let i = 0;
|
|
327
|
+
while (i < lines.length) {
|
|
328
|
+
const line = lines[i].trim();
|
|
329
|
+
// Look for timestamp line (00:00:00.000 --> 00:00:00.000)
|
|
330
|
+
const timestampMatch = line.match(/(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/);
|
|
331
|
+
if (timestampMatch) {
|
|
332
|
+
const startTime = parseVTTTimestamp(timestampMatch[1]);
|
|
333
|
+
const endTime = parseVTTTimestamp(timestampMatch[2]);
|
|
334
|
+
// Collect text lines until empty line
|
|
335
|
+
const textLines = [];
|
|
336
|
+
i++;
|
|
337
|
+
while (i < lines.length && lines[i].trim() !== '') {
|
|
338
|
+
// Remove VTT tags like <c> </c>
|
|
339
|
+
const cleanedLine = lines[i].replace(/<[^>]+>/g, '').trim();
|
|
340
|
+
if (cleanedLine && !cleanedLine.startsWith('[')) {
|
|
341
|
+
textLines.push(cleanedLine);
|
|
342
|
+
}
|
|
343
|
+
i++;
|
|
344
|
+
}
|
|
345
|
+
if (textLines.length > 0) {
|
|
346
|
+
segments.push({
|
|
347
|
+
start: startTime,
|
|
348
|
+
duration: endTime - startTime,
|
|
349
|
+
text: textLines.join(' '),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
return segments;
|
|
356
|
+
}
|
|
357
|
+
function parseVTTTimestamp(timestamp) {
|
|
358
|
+
const parts = timestamp.split(':');
|
|
359
|
+
const hours = parseInt(parts[0]);
|
|
360
|
+
const minutes = parseInt(parts[1]);
|
|
361
|
+
const secondsParts = parts[2].split('.');
|
|
362
|
+
const seconds = parseInt(secondsParts[0]);
|
|
363
|
+
const ms = parseInt(secondsParts[1] || '0');
|
|
364
|
+
return hours * 3600 + minutes * 60 + seconds + ms / 1000;
|
|
365
|
+
}
|
|
366
|
+
function formatTranscriptOutput(videoId, segments, format) {
|
|
367
|
+
// Deduplicate consecutive similar segments
|
|
368
|
+
const deduped = [];
|
|
369
|
+
for (const seg of segments) {
|
|
370
|
+
if (deduped.length === 0 || deduped[deduped.length - 1].text !== seg.text) {
|
|
371
|
+
deduped.push(seg);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (format === 'text') {
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
videoId,
|
|
378
|
+
format: 'text',
|
|
379
|
+
text: deduped.map(s => s.text).join(' '),
|
|
380
|
+
segmentCount: deduped.length,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (format === 'srt') {
|
|
384
|
+
const srtLines = [];
|
|
385
|
+
deduped.forEach((seg, idx) => {
|
|
386
|
+
srtLines.push(String(idx + 1));
|
|
387
|
+
srtLines.push(`${formatTimestamp(seg.start)} --> ${formatTimestamp(seg.start + seg.duration)}`);
|
|
388
|
+
srtLines.push(seg.text);
|
|
389
|
+
srtLines.push('');
|
|
390
|
+
});
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
videoId,
|
|
394
|
+
format: 'srt',
|
|
395
|
+
srt: srtLines.join('\n'),
|
|
396
|
+
segmentCount: deduped.length,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// Default: timestamped
|
|
400
|
+
return {
|
|
401
|
+
success: true,
|
|
402
|
+
videoId,
|
|
403
|
+
format: 'timestamped',
|
|
404
|
+
segments: deduped.map(s => ({
|
|
405
|
+
timestamp: formatDuration(s.start),
|
|
406
|
+
startSeconds: s.start,
|
|
407
|
+
text: s.text,
|
|
408
|
+
})),
|
|
409
|
+
segmentCount: deduped.length,
|
|
410
|
+
totalDuration: deduped.length > 0
|
|
411
|
+
? formatDuration(deduped[deduped.length - 1].start + deduped[deduped.length - 1].duration)
|
|
412
|
+
: '0:00',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
// ============================================================================
|
|
416
|
+
// Get Chapters
|
|
417
|
+
// ============================================================================
|
|
418
|
+
async function getChapters(params) {
|
|
419
|
+
const { url } = params;
|
|
420
|
+
if (!url || typeof url !== 'string') {
|
|
421
|
+
throw new Error('url is required');
|
|
422
|
+
}
|
|
423
|
+
const videoId = extractVideoId(url);
|
|
424
|
+
if (!(await checkYtDlp())) {
|
|
425
|
+
throw new Error('yt-dlp is not installed. Install it with: pip install yt-dlp');
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const { stdout } = await execAsync(`${getYtDlpCommand()} --dump-json --no-download "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 10 * 1024 * 1024 });
|
|
429
|
+
const info = JSON.parse(stdout);
|
|
430
|
+
if (!info.chapters || info.chapters.length === 0) {
|
|
431
|
+
return {
|
|
432
|
+
success: true,
|
|
433
|
+
videoId,
|
|
434
|
+
hasChapters: false,
|
|
435
|
+
chapters: [],
|
|
436
|
+
message: 'This video does not have chapters',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
videoId,
|
|
442
|
+
title: info.title,
|
|
443
|
+
hasChapters: true,
|
|
444
|
+
chapterCount: info.chapters.length,
|
|
445
|
+
chapters: info.chapters.map((ch, idx) => ({
|
|
446
|
+
index: idx + 1,
|
|
447
|
+
title: ch.title,
|
|
448
|
+
startTime: formatDuration(ch.start_time),
|
|
449
|
+
startSeconds: ch.start_time,
|
|
450
|
+
endTime: formatDuration(ch.end_time),
|
|
451
|
+
endSeconds: ch.end_time,
|
|
452
|
+
duration: formatDuration(ch.end_time - ch.start_time),
|
|
453
|
+
})),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
throw new Error(`Failed to get chapters: ${error instanceof Error ? error.message : String(error)}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Download Audio
|
|
462
|
+
// ============================================================================
|
|
463
|
+
async function downloadAudio(params) {
|
|
464
|
+
const { url, outputPath } = params;
|
|
465
|
+
if (!url || typeof url !== 'string') {
|
|
466
|
+
throw new Error('url is required');
|
|
467
|
+
}
|
|
468
|
+
const videoId = extractVideoId(url);
|
|
469
|
+
if (!(await checkYtDlp())) {
|
|
470
|
+
throw new Error('yt-dlp is not installed. Install it with: pip install yt-dlp');
|
|
471
|
+
}
|
|
472
|
+
ensureDirectories();
|
|
473
|
+
// Determine output path
|
|
474
|
+
let finalPath;
|
|
475
|
+
if (outputPath && typeof outputPath === 'string') {
|
|
476
|
+
finalPath = outputPath;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
// Get video title for filename
|
|
480
|
+
try {
|
|
481
|
+
const { stdout } = await execAsync(`${getYtDlpCommand()} --get-title "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 1024 * 1024 });
|
|
482
|
+
const title = stdout.trim().replace(/[<>:"/\\|?*]/g, '_').slice(0, 100);
|
|
483
|
+
finalPath = path.join(DOWNLOADS_DIR, `${title}_${videoId}.mp3`);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
finalPath = path.join(DOWNLOADS_DIR, `${videoId}.mp3`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Ensure output directory exists
|
|
490
|
+
const dir = path.dirname(finalPath);
|
|
491
|
+
if (!fs.existsSync(dir)) {
|
|
492
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
await execAsync(`${getYtDlpCommand()} -x --audio-format mp3 --audio-quality 0 -o "${finalPath.replace('.mp3', '')}.%(ext)s" "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 10 * 1024 * 1024 });
|
|
496
|
+
// Check if file exists
|
|
497
|
+
if (!fs.existsSync(finalPath)) {
|
|
498
|
+
// Try with the exact output name
|
|
499
|
+
const altPath = finalPath.replace('.mp3', '.mp3');
|
|
500
|
+
if (fs.existsSync(altPath)) {
|
|
501
|
+
return {
|
|
502
|
+
success: true,
|
|
503
|
+
videoId,
|
|
504
|
+
format: 'mp3',
|
|
505
|
+
path: altPath,
|
|
506
|
+
size: fs.statSync(altPath).size,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// Look for any mp3 file in the directory with the video ID
|
|
510
|
+
const files = fs.readdirSync(dir);
|
|
511
|
+
const mp3File = files.find(f => f.includes(videoId) && f.endsWith('.mp3'));
|
|
512
|
+
if (mp3File) {
|
|
513
|
+
const foundPath = path.join(dir, mp3File);
|
|
514
|
+
return {
|
|
515
|
+
success: true,
|
|
516
|
+
videoId,
|
|
517
|
+
format: 'mp3',
|
|
518
|
+
path: foundPath,
|
|
519
|
+
size: fs.statSync(foundPath).size,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const stats = fs.statSync(finalPath);
|
|
524
|
+
return {
|
|
525
|
+
success: true,
|
|
526
|
+
videoId,
|
|
527
|
+
format: 'mp3',
|
|
528
|
+
path: finalPath,
|
|
529
|
+
size: stats.size,
|
|
530
|
+
sizeFormatted: `${(stats.size / (1024 * 1024)).toFixed(2)} MB`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
throw new Error(`Failed to download audio: ${error instanceof Error ? error.message : String(error)}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// ============================================================================
|
|
538
|
+
// Download Video
|
|
539
|
+
// ============================================================================
|
|
540
|
+
async function downloadVideo(params) {
|
|
541
|
+
const { url, quality = '720p', outputPath } = params;
|
|
542
|
+
if (!url || typeof url !== 'string') {
|
|
543
|
+
throw new Error('url is required');
|
|
544
|
+
}
|
|
545
|
+
const videoId = extractVideoId(url);
|
|
546
|
+
if (!(await checkYtDlp())) {
|
|
547
|
+
throw new Error('yt-dlp is not installed. Install it with: pip install yt-dlp');
|
|
548
|
+
}
|
|
549
|
+
ensureDirectories();
|
|
550
|
+
// Quality mapping
|
|
551
|
+
const qualityMap = {
|
|
552
|
+
'best': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
|
553
|
+
'1080p': 'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]',
|
|
554
|
+
'720p': 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]',
|
|
555
|
+
'480p': 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480][ext=mp4]',
|
|
556
|
+
'worst': 'worstvideo[ext=mp4]+worstaudio[ext=m4a]/worst[ext=mp4]/worst',
|
|
557
|
+
};
|
|
558
|
+
const formatStr = qualityMap[quality] || qualityMap['720p'];
|
|
559
|
+
// Determine output path
|
|
560
|
+
let finalPath;
|
|
561
|
+
if (outputPath && typeof outputPath === 'string') {
|
|
562
|
+
finalPath = outputPath;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
try {
|
|
566
|
+
const { stdout } = await execAsync(`${getYtDlpCommand()} --get-title "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 1024 * 1024 });
|
|
567
|
+
const title = stdout.trim().replace(/[<>:"/\\|?*]/g, '_').slice(0, 100);
|
|
568
|
+
finalPath = path.join(DOWNLOADS_DIR, `${title}_${videoId}.mp4`);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
finalPath = path.join(DOWNLOADS_DIR, `${videoId}.mp4`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// Ensure output directory exists
|
|
575
|
+
const dir = path.dirname(finalPath);
|
|
576
|
+
if (!fs.existsSync(dir)) {
|
|
577
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await execAsync(`${getYtDlpCommand()} -f "${formatStr}" --merge-output-format mp4 -o "${finalPath}" "https://www.youtube.com/watch?v=${videoId}"`, { maxBuffer: 50 * 1024 * 1024, timeout: 300000 });
|
|
581
|
+
// Verify file exists
|
|
582
|
+
if (!fs.existsSync(finalPath)) {
|
|
583
|
+
// Look for any mp4 with the video ID
|
|
584
|
+
const files = fs.readdirSync(dir);
|
|
585
|
+
const mp4File = files.find(f => f.includes(videoId) && f.endsWith('.mp4'));
|
|
586
|
+
if (mp4File) {
|
|
587
|
+
finalPath = path.join(dir, mp4File);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
throw new Error('Download completed but file not found');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const stats = fs.statSync(finalPath);
|
|
594
|
+
return {
|
|
595
|
+
success: true,
|
|
596
|
+
videoId,
|
|
597
|
+
format: 'mp4',
|
|
598
|
+
quality,
|
|
599
|
+
path: finalPath,
|
|
600
|
+
size: stats.size,
|
|
601
|
+
sizeFormatted: `${(stats.size / (1024 * 1024)).toFixed(2)} MB`,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
throw new Error(`Failed to download video: ${error instanceof Error ? error.message : String(error)}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// Summarize
|
|
610
|
+
// ============================================================================
|
|
611
|
+
async function summarize(params) {
|
|
612
|
+
const { url, style = 'brief' } = params;
|
|
613
|
+
if (!url || typeof url !== 'string') {
|
|
614
|
+
throw new Error('url is required');
|
|
615
|
+
}
|
|
616
|
+
if (!GOOGLE_API_KEY) {
|
|
617
|
+
throw new Error('GOOGLE_API_KEY environment variable is not set (needed for summarization)');
|
|
618
|
+
}
|
|
619
|
+
const videoId = extractVideoId(url);
|
|
620
|
+
// Get transcript
|
|
621
|
+
const transcriptResult = await getTranscript({ url, format: 'text' });
|
|
622
|
+
if (!transcriptResult.success || !transcriptResult.text) {
|
|
623
|
+
throw new Error('Failed to get transcript for summarization');
|
|
624
|
+
}
|
|
625
|
+
// Get metadata for context
|
|
626
|
+
const metadataResult = await getMetadata({ url });
|
|
627
|
+
const title = metadataResult.title || 'Unknown';
|
|
628
|
+
const channel = metadataResult.channel || 'Unknown';
|
|
629
|
+
// Build prompt based on style
|
|
630
|
+
let promptStyle = '';
|
|
631
|
+
switch (style) {
|
|
632
|
+
case 'detailed':
|
|
633
|
+
promptStyle = 'Provide a detailed summary broken into sections covering the main topics discussed. Include key points under each section.';
|
|
634
|
+
break;
|
|
635
|
+
case 'bullets':
|
|
636
|
+
promptStyle = 'Provide a summary as a bulleted list of the key points and takeaways.';
|
|
637
|
+
break;
|
|
638
|
+
default: // brief
|
|
639
|
+
promptStyle = 'Provide a concise 2-3 paragraph summary of the main content and key takeaways.';
|
|
640
|
+
}
|
|
641
|
+
const prompt = `You are analyzing a YouTube video transcript.
|
|
642
|
+
|
|
643
|
+
Video: "${title}" by ${channel}
|
|
644
|
+
|
|
645
|
+
${promptStyle}
|
|
646
|
+
|
|
647
|
+
Transcript:
|
|
648
|
+
${transcriptResult.text.slice(0, 30000)}${transcriptResult.text.length > 30000 ? '\n\n[Transcript truncated...]' : ''}`;
|
|
649
|
+
// Call Gemini
|
|
650
|
+
const endpoint = `${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}`;
|
|
651
|
+
const response = await fetch(endpoint, {
|
|
652
|
+
method: 'POST',
|
|
653
|
+
headers: { 'Content-Type': 'application/json' },
|
|
654
|
+
body: JSON.stringify({
|
|
655
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
656
|
+
}),
|
|
657
|
+
});
|
|
658
|
+
if (!response.ok) {
|
|
659
|
+
const errorText = await response.text();
|
|
660
|
+
throw new Error(`Gemini API error: ${errorText}`);
|
|
661
|
+
}
|
|
662
|
+
const data = await response.json();
|
|
663
|
+
const summary = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
664
|
+
return {
|
|
665
|
+
success: true,
|
|
666
|
+
videoId,
|
|
667
|
+
title,
|
|
668
|
+
channel,
|
|
669
|
+
style,
|
|
670
|
+
summary: summary.trim(),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// Full Analysis (Hybrid)
|
|
675
|
+
// ============================================================================
|
|
676
|
+
async function analyze(params) {
|
|
677
|
+
const { url, mode = 'transcript', questions, extractCode = false, outputPath } = params;
|
|
678
|
+
if (!url || typeof url !== 'string') {
|
|
679
|
+
throw new Error('url is required');
|
|
680
|
+
}
|
|
681
|
+
if (!GOOGLE_API_KEY) {
|
|
682
|
+
throw new Error('GOOGLE_API_KEY environment variable is not set');
|
|
683
|
+
}
|
|
684
|
+
const videoId = extractVideoId(url);
|
|
685
|
+
ensureDirectories();
|
|
686
|
+
const result = {
|
|
687
|
+
success: true,
|
|
688
|
+
videoId,
|
|
689
|
+
mode,
|
|
690
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
691
|
+
};
|
|
692
|
+
// Get metadata
|
|
693
|
+
const metadata = await getMetadata({ url });
|
|
694
|
+
result.metadata = {
|
|
695
|
+
title: metadata.title,
|
|
696
|
+
channel: metadata.channel,
|
|
697
|
+
duration: metadata.duration,
|
|
698
|
+
publishedAt: metadata.publishedAt,
|
|
699
|
+
};
|
|
700
|
+
// Get chapters if available
|
|
701
|
+
const chapters = await getChapters({ url });
|
|
702
|
+
if (chapters.hasChapters) {
|
|
703
|
+
result.chapters = chapters.chapters;
|
|
704
|
+
}
|
|
705
|
+
// Get transcript
|
|
706
|
+
let transcriptText = '';
|
|
707
|
+
try {
|
|
708
|
+
const transcript = await getTranscript({ url, format: 'text' });
|
|
709
|
+
transcriptText = transcript.text || '';
|
|
710
|
+
result.transcriptLength = transcriptText.length;
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
result.transcriptError = error instanceof Error ? error.message : String(error);
|
|
714
|
+
}
|
|
715
|
+
// Transcript-based analysis
|
|
716
|
+
if ((mode === 'transcript' || mode === 'full') && transcriptText) {
|
|
717
|
+
const questionsText = Array.isArray(questions) && questions.length > 0
|
|
718
|
+
? `\n\nSpecific questions to answer:\n${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}`
|
|
719
|
+
: '';
|
|
720
|
+
const codeExtractionText = extractCode
|
|
721
|
+
? '\n\nAlso identify and extract any code snippets, commands, or technical configurations mentioned.'
|
|
722
|
+
: '';
|
|
723
|
+
const analysisPrompt = `Analyze this YouTube video transcript comprehensively.
|
|
724
|
+
|
|
725
|
+
Video: "${metadata.title}" by ${metadata.channel}
|
|
726
|
+
Duration: ${metadata.duration}
|
|
727
|
+
|
|
728
|
+
Provide:
|
|
729
|
+
1. A detailed summary of the content
|
|
730
|
+
2. Key points and takeaways
|
|
731
|
+
3. Main topics/themes discussed
|
|
732
|
+
4. Any tools, technologies, or resources mentioned
|
|
733
|
+
5. Target audience and skill level${questionsText}${codeExtractionText}
|
|
734
|
+
|
|
735
|
+
Transcript:
|
|
736
|
+
${transcriptText.slice(0, 40000)}${transcriptText.length > 40000 ? '\n\n[Transcript truncated...]' : ''}`;
|
|
737
|
+
const response = await fetch(`${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}`, {
|
|
738
|
+
method: 'POST',
|
|
739
|
+
headers: { 'Content-Type': 'application/json' },
|
|
740
|
+
body: JSON.stringify({
|
|
741
|
+
contents: [{ parts: [{ text: analysisPrompt }] }],
|
|
742
|
+
}),
|
|
743
|
+
});
|
|
744
|
+
if (response.ok) {
|
|
745
|
+
const data = await response.json();
|
|
746
|
+
result.transcriptAnalysis = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Visual analysis with Gemini (if mode is visual or full)
|
|
750
|
+
if (mode === 'visual' || mode === 'full') {
|
|
751
|
+
// Download video for visual analysis
|
|
752
|
+
try {
|
|
753
|
+
const downloadResult = await downloadVideo({ url, quality: '480p' });
|
|
754
|
+
if (downloadResult.path && fs.existsSync(downloadResult.path)) {
|
|
755
|
+
// Upload video to Gemini Files API
|
|
756
|
+
const videoContent = fs.readFileSync(downloadResult.path);
|
|
757
|
+
const uploadResponse = await fetch(`https://generativelanguage.googleapis.com/upload/v1beta/files?key=${GOOGLE_API_KEY}`, {
|
|
758
|
+
method: 'POST',
|
|
759
|
+
headers: {
|
|
760
|
+
'Content-Type': 'video/mp4',
|
|
761
|
+
'X-Goog-Upload-Protocol': 'raw',
|
|
762
|
+
'X-Goog-Upload-Command': 'upload, finalize',
|
|
763
|
+
},
|
|
764
|
+
body: videoContent,
|
|
765
|
+
});
|
|
766
|
+
if (uploadResponse.ok) {
|
|
767
|
+
const uploadData = await uploadResponse.json();
|
|
768
|
+
const fileUri = uploadData.file?.uri;
|
|
769
|
+
if (fileUri) {
|
|
770
|
+
// Wait for processing
|
|
771
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
772
|
+
// Analyze with Gemini
|
|
773
|
+
const visualPrompt = `Analyze this video comprehensively. Focus on:
|
|
774
|
+
1. Visual demonstrations and what's being shown on screen
|
|
775
|
+
2. Any text, code, diagrams, or UI elements visible
|
|
776
|
+
3. Key visual moments and their significance
|
|
777
|
+
4. Production quality and presentation style
|
|
778
|
+
|
|
779
|
+
${Array.isArray(questions) && questions.length > 0 ? `\nAnswer these specific questions:\n${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}` : ''}`;
|
|
780
|
+
const visualResponse = await fetch(`${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}`, {
|
|
781
|
+
method: 'POST',
|
|
782
|
+
headers: { 'Content-Type': 'application/json' },
|
|
783
|
+
body: JSON.stringify({
|
|
784
|
+
contents: [{
|
|
785
|
+
parts: [
|
|
786
|
+
{ fileData: { mimeType: 'video/mp4', fileUri } },
|
|
787
|
+
{ text: visualPrompt },
|
|
788
|
+
],
|
|
789
|
+
}],
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
if (visualResponse.ok) {
|
|
793
|
+
const visualData = await visualResponse.json();
|
|
794
|
+
result.visualAnalysis = visualData.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// Cleanup downloaded video (optional - keep if user might want it)
|
|
799
|
+
// fs.unlinkSync(downloadResult.path);
|
|
800
|
+
result.downloadedVideoPath = downloadResult.path;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
result.visualAnalysisError = error instanceof Error ? error.message : String(error);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// Save to file if requested
|
|
808
|
+
if (outputPath && typeof outputPath === 'string') {
|
|
809
|
+
const dir = path.dirname(outputPath);
|
|
810
|
+
if (!fs.existsSync(dir)) {
|
|
811
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
812
|
+
}
|
|
813
|
+
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));
|
|
814
|
+
result.savedTo = outputPath;
|
|
815
|
+
}
|
|
816
|
+
return result;
|
|
817
|
+
}
|
|
818
|
+
// ============================================================================
|
|
819
|
+
// Extract Code
|
|
820
|
+
// ============================================================================
|
|
821
|
+
async function extractCodeFromVideo(params) {
|
|
822
|
+
const { url, language } = params;
|
|
823
|
+
if (!url || typeof url !== 'string') {
|
|
824
|
+
throw new Error('url is required');
|
|
825
|
+
}
|
|
826
|
+
if (!GOOGLE_API_KEY) {
|
|
827
|
+
throw new Error('GOOGLE_API_KEY environment variable is not set');
|
|
828
|
+
}
|
|
829
|
+
const videoId = extractVideoId(url);
|
|
830
|
+
// Get transcript
|
|
831
|
+
const transcript = await getTranscript({ url, format: 'text' });
|
|
832
|
+
const transcriptText = transcript.text || '';
|
|
833
|
+
if (!transcriptText) {
|
|
834
|
+
throw new Error('No transcript available for code extraction');
|
|
835
|
+
}
|
|
836
|
+
const langHint = language && typeof language === 'string'
|
|
837
|
+
? `Focus especially on ${language} code.`
|
|
838
|
+
: 'Detect the programming language automatically.';
|
|
839
|
+
const prompt = `Extract all code snippets, commands, and technical configurations from this programming video transcript.
|
|
840
|
+
|
|
841
|
+
${langHint}
|
|
842
|
+
|
|
843
|
+
For each code snippet found:
|
|
844
|
+
1. Identify what it does
|
|
845
|
+
2. Extract the complete code
|
|
846
|
+
3. Note any dependencies or requirements mentioned
|
|
847
|
+
4. Include the approximate timestamp context if mentioned
|
|
848
|
+
|
|
849
|
+
Format each code block with proper markdown syntax highlighting.
|
|
850
|
+
|
|
851
|
+
Transcript:
|
|
852
|
+
${transcriptText.slice(0, 30000)}`;
|
|
853
|
+
const response = await fetch(`${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}`, {
|
|
854
|
+
method: 'POST',
|
|
855
|
+
headers: { 'Content-Type': 'application/json' },
|
|
856
|
+
body: JSON.stringify({
|
|
857
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
858
|
+
}),
|
|
859
|
+
});
|
|
860
|
+
if (!response.ok) {
|
|
861
|
+
const errorText = await response.text();
|
|
862
|
+
throw new Error(`Gemini API error: ${errorText}`);
|
|
863
|
+
}
|
|
864
|
+
const data = await response.json();
|
|
865
|
+
const extraction = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
866
|
+
return {
|
|
867
|
+
success: true,
|
|
868
|
+
videoId,
|
|
869
|
+
language: language || 'auto-detected',
|
|
870
|
+
extraction: extraction.trim(),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
// ============================================================================
|
|
874
|
+
// Backend Export
|
|
875
|
+
// ============================================================================
|
|
876
|
+
export const youtubeBackend = {
|
|
877
|
+
name: 'youtube',
|
|
878
|
+
description: 'YouTube video analysis: metadata, transcripts, chapters, downloads, and AI-powered analysis with Gemini.',
|
|
879
|
+
actions,
|
|
880
|
+
async execute(action, params) {
|
|
881
|
+
switch (action) {
|
|
882
|
+
// Metadata
|
|
883
|
+
case 'get_metadata':
|
|
884
|
+
return getMetadata(params);
|
|
885
|
+
// Transcript
|
|
886
|
+
case 'get_transcript':
|
|
887
|
+
return getTranscript(params);
|
|
888
|
+
// Chapters
|
|
889
|
+
case 'get_chapters':
|
|
890
|
+
return getChapters(params);
|
|
891
|
+
// Downloads
|
|
892
|
+
case 'download_audio':
|
|
893
|
+
return downloadAudio(params);
|
|
894
|
+
case 'download_video':
|
|
895
|
+
return downloadVideo(params);
|
|
896
|
+
// Analysis
|
|
897
|
+
case 'summarize':
|
|
898
|
+
return summarize(params);
|
|
899
|
+
case 'analyze':
|
|
900
|
+
return analyze(params);
|
|
901
|
+
case 'extract_code':
|
|
902
|
+
return extractCodeFromVideo(params);
|
|
903
|
+
default:
|
|
904
|
+
throw new Error('Unknown action: ' + action);
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
async healthCheck() {
|
|
908
|
+
const startTime = Date.now();
|
|
909
|
+
const issues = [];
|
|
910
|
+
// Check yt-dlp
|
|
911
|
+
const hasYtDlp = await checkYtDlp();
|
|
912
|
+
if (!hasYtDlp) {
|
|
913
|
+
issues.push('yt-dlp not installed');
|
|
914
|
+
}
|
|
915
|
+
// Check Google API key (for analysis features)
|
|
916
|
+
if (!GOOGLE_API_KEY) {
|
|
917
|
+
issues.push('GOOGLE_API_KEY not set (needed for analysis)');
|
|
918
|
+
}
|
|
919
|
+
// YouTube API key is optional
|
|
920
|
+
if (!YOUTUBE_API_KEY) {
|
|
921
|
+
// Not an error, just informational
|
|
922
|
+
}
|
|
923
|
+
if (issues.length > 0) {
|
|
924
|
+
return {
|
|
925
|
+
status: issues.includes('yt-dlp not installed') ? 'unavailable' : 'degraded',
|
|
926
|
+
latency_ms: Date.now() - startTime,
|
|
927
|
+
error: issues.join('; '),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
status: 'healthy',
|
|
932
|
+
latency_ms: Date.now() - startTime,
|
|
933
|
+
};
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
//# sourceMappingURL=youtube.js.map
|