Add AI summarization feature with GPT-5.1

- Add summarization service (text, files, YouTube videos)
- Add Summarize tab with Text/Files/YouTube Link modes
- Add full pipeline endpoint: download + transcribe + summarize
- Support for concise, detailed, and bullet point styles
- Real-time progress tracking with SSE
- Multi-language output support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-11-25 10:49:39 +08:00
parent 849412c3bd
commit a974846efc
6 changed files with 1124 additions and 49 deletions

View File

@ -634,3 +634,343 @@ document.getElementById('translate-file-form').addEventListener('submit', async
setLoading(button, false);
}
});
// ==================== SUMMARIZE TAB ====================
// Mode switching
const summarizeModeBtns = document.querySelectorAll('.summarize-mode-selector .mode-btn');
const summarizeTextMode = document.getElementById('summarize-text-mode');
const summarizeFileMode = document.getElementById('summarize-file-mode');
const summarizeLinkMode = document.getElementById('summarize-link-mode');
summarizeModeBtns.forEach(btn => {
btn.addEventListener('click', () => {
summarizeModeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
summarizeTextMode.style.display = 'none';
summarizeFileMode.style.display = 'none';
if (summarizeLinkMode) summarizeLinkMode.style.display = 'none';
if (btn.dataset.mode === 'text') {
summarizeTextMode.style.display = 'block';
} else if (btn.dataset.mode === 'file') {
summarizeFileMode.style.display = 'block';
} else if (btn.dataset.mode === 'link' && summarizeLinkMode) {
summarizeLinkMode.style.display = 'block';
}
});
});
// Text summarization form
document.getElementById('summarize-text-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.getElementById('summarize-text-btn');
const text = document.getElementById('summarize-input').value;
const style = document.getElementById('summarize-style').value;
const language = document.getElementById('summarize-language').value;
if (!text.trim()) {
showResult('summarize-text-result', false, '<h3>Error</h3><p>Please enter text to summarize</p>');
return;
}
setLoading(button, true);
document.getElementById('summarize-text-result').classList.remove('show');
try {
const response = await fetch(`${API_URL}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, style, language })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Summarization failed');
showResult('summarize-text-result', true, `
<h3>Summary Complete!</h3>
<p><strong>Model:</strong> ${data.model} | <strong>Style:</strong> ${data.style} | <strong>Chunks:</strong> ${data.chunks}</p>
<div class="summary-output">${data.summary}</div>
`);
} catch (error) {
showResult('summarize-text-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// File summarization - Drag & Drop
let summarizeSelectedFiles = [];
const summarizeDropZone = document.getElementById('summarize-drop-zone');
const summarizeFileInput = document.getElementById('summarize-file-input');
const summarizeSelectedFilesDiv = document.getElementById('summarize-selected-files');
const summarizeFilesList = document.getElementById('summarize-files-list');
const summarizeFileBtn = document.getElementById('summarize-file-btn');
const summarizeClearFilesBtn = document.getElementById('summarize-clear-files');
function updateSummarizeFilesList() {
if (!summarizeSelectedFilesDiv || !summarizeFileBtn || !summarizeFilesList) return;
if (summarizeSelectedFiles.length === 0) {
summarizeSelectedFilesDiv.style.display = 'none';
summarizeFileBtn.disabled = true;
return;
}
summarizeSelectedFilesDiv.style.display = 'block';
summarizeFileBtn.disabled = false;
summarizeFilesList.innerHTML = summarizeSelectedFiles.map((file, index) => `
<li>
<span class="file-name">${file.name}</span>
<span class="file-size">${formatSize(file.size)}</span>
<button type="button" class="remove-file" data-index="${index}">x</button>
</li>
`).join('');
summarizeFilesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
summarizeSelectedFiles.splice(parseInt(btn.dataset.index), 1);
updateSummarizeFilesList();
});
});
}
function addSummarizeFiles(files) {
console.log('Adding files:', files);
const textFiles = Array.from(files).filter(f =>
f.type === 'text/plain' || f.name.endsWith('.txt')
);
console.log('Filtered text files:', textFiles);
summarizeSelectedFiles = [...summarizeSelectedFiles, ...textFiles];
updateSummarizeFilesList();
}
if (summarizeDropZone) {
summarizeDropZone.addEventListener('click', () => {
console.log('Drop zone clicked');
summarizeFileInput.click();
});
summarizeDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
summarizeDropZone.classList.add('drag-over');
});
summarizeDropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
summarizeDropZone.classList.remove('drag-over');
});
summarizeDropZone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Files dropped:', e.dataTransfer.files);
summarizeDropZone.classList.remove('drag-over');
addSummarizeFiles(e.dataTransfer.files);
});
}
if (summarizeFileInput) {
summarizeFileInput.addEventListener('change', () => {
console.log('File input changed:', summarizeFileInput.files);
addSummarizeFiles(summarizeFileInput.files);
summarizeFileInput.value = '';
});
}
if (summarizeClearFilesBtn) {
summarizeClearFilesBtn.addEventListener('click', () => {
summarizeSelectedFiles = [];
updateSummarizeFilesList();
});
}
// File summarization form submit
document.getElementById('summarize-file-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (summarizeSelectedFiles.length === 0) return;
const button = summarizeFileBtn;
const style = document.getElementById('summarize-file-style').value;
const language = document.getElementById('summarize-file-language').value;
setLoading(button, true);
document.getElementById('summarize-file-result').classList.remove('show');
const formData = new FormData();
summarizeSelectedFiles.forEach(file => formData.append('files', file));
formData.append('style', style);
formData.append('language', language);
try {
const response = await fetch(`${API_URL}/summarize-file`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Summarization failed');
showResult('summarize-file-result', true, `
<h3>Summarization Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files summarized</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
${r.fileName || r.filePath}
${r.success && r.summaryUrl ? `<a href="${r.summaryUrl}" target="_blank">View</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.summary ? `
<h4>Preview (first file):</h4>
<div class="summary-output">${data.results[0].summary.substring(0, 1000)}${data.results[0].summary.length > 1000 ? '...' : ''}</div>
` : ''}
`);
summarizeSelectedFiles = [];
updateSummarizeFilesList();
} catch (error) {
showResult('summarize-file-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// ==================== SUMMARIZE LINK MODE (Full Pipeline) ====================
const summarizeLinkForm = document.getElementById('summarize-link-form');
if (summarizeLinkForm) {
const linkProgress = document.getElementById('summarize-link-progress');
const linkProgressFill = document.getElementById('summarize-link-progress-fill');
const linkProgressTitle = document.getElementById('summarize-link-progress-title');
const linkProgressPercent = document.getElementById('summarize-link-progress-percent');
const linkProgressPhase = document.getElementById('summarize-link-progress-phase');
const linkProgressSpeed = document.getElementById('summarize-link-progress-speed');
const linkProgressCurrent = document.getElementById('summarize-link-progress-current');
const linkProgressEta = document.getElementById('summarize-link-progress-eta');
function resetLinkProgress() {
if (linkProgressFill) linkProgressFill.style.width = '0%';
if (linkProgressPercent) linkProgressPercent.textContent = '0%';
if (linkProgressTitle) linkProgressTitle.textContent = 'Processing...';
if (linkProgressPhase) linkProgressPhase.textContent = '';
if (linkProgressSpeed) linkProgressSpeed.textContent = '';
if (linkProgressCurrent) linkProgressCurrent.textContent = '';
if (linkProgressEta) linkProgressEta.textContent = '';
}
summarizeLinkForm.addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.getElementById('summarize-link-btn');
const url = document.getElementById('summarize-url').value;
const style = document.getElementById('summarize-link-style').value;
const language = document.getElementById('summarize-link-language').value;
const resultDiv = document.getElementById('summarize-link-result');
setLoading(button, true);
resetLinkProgress();
linkProgress.style.display = 'block';
resultDiv.classList.remove('show');
const params = new URLSearchParams({ url, style, language });
const eventSource = new EventSource(`${API_URL}/summarize-stream?${params}`);
eventSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
linkProgressTitle.textContent = data.message;
if (data.percent !== undefined) {
linkProgressFill.style.width = `${data.percent}%`;
linkProgressPercent.textContent = `${Math.round(data.percent)}%`;
}
});
eventSource.addEventListener('info', (e) => {
const data = JSON.parse(e.data);
linkProgressTitle.textContent = data.totalVideos > 1
? `Processing playlist: ${data.playlistTitle} (${data.totalVideos} videos)`
: `Processing: ${data.title}`;
});
eventSource.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
linkProgressFill.style.width = `${data.percent}%`;
linkProgressPercent.textContent = `${Math.round(data.percent)}%`;
linkProgressPhase.textContent = data.phaseLabel || '';
if (data.speed) linkProgressSpeed.textContent = data.speed;
if (data.title) {
linkProgressCurrent.innerHTML = `${data.phaseLabel}: <span class="video-title">${data.title}</span>`;
}
if (data.totalVideos > 1) {
linkProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`;
}
});
eventSource.addEventListener('video-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Downloaded: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('transcribe-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Transcribed: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('summarize-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Summarized: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('complete', (e) => {
const data = JSON.parse(e.data);
eventSource.close();
linkProgressFill.style.width = '100%';
linkProgressPercent.textContent = '100%';
linkProgressTitle.textContent = 'Complete!';
linkProgressPhase.textContent = '';
linkProgressEta.textContent = `Total: ${formatTime(data.totalTime)}`;
showResult('summarize-link-result', true, `
<h3>Pipeline Complete!</h3>
${data.playlistTitle ? `<p>Playlist: ${data.playlistTitle}</p>` : ''}
<p>Downloaded: ${data.downloadedCount}/${data.totalVideos} | Transcribed: ${data.transcribedCount} | Summarized: ${data.summarizedCount}</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
<strong>${r.title}</strong>
${r.summaryUrl ? `<a href="${r.summaryUrl}" target="_blank">Summary</a>` : ''}
${r.transcriptionUrl ? `<a href="${r.transcriptionUrl}" target="_blank">Transcript</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.summary ? `
<h4>Summary Preview:</h4>
<div class="summary-output">${data.results[0].summary.substring(0, 2000)}${data.results[0].summary.length > 2000 ? '...' : ''}</div>
` : ''}
`);
setLoading(button, false);
});
eventSource.addEventListener('error', (e) => {
let errorMsg = 'Processing failed';
try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {}
eventSource.close();
linkProgress.style.display = 'none';
showResult('summarize-link-result', false, `<h3>Error</h3><p>${errorMsg}</p>`);
setLoading(button, false);
});
eventSource.onerror = () => {
eventSource.close();
linkProgress.style.display = 'none';
showResult('summarize-link-result', false, `<h3>Error</h3><p>Connection lost</p>`);
setLoading(button, false);
};
});
}

View File

@ -19,6 +19,7 @@
<button class="tab" data-tab="transcribe">Transcribe</button>
<button class="tab" data-tab="process">Download + Transcribe</button>
<button class="tab" data-tab="translate">Translate</button>
<button class="tab" data-tab="summarize">Summarize</button>
</nav>
<!-- Download Tab -->
@ -338,6 +339,171 @@
<div id="translate-file-result" class="result"></div>
</div>
</section>
<!-- Summarize Tab -->
<section id="summarize" class="tab-content">
<h2>Summarize Text (GPT-5.1)</h2>
<!-- Mode selector -->
<div class="summarize-mode-selector">
<button type="button" class="mode-btn active" data-mode="text">Text</button>
<button type="button" class="mode-btn" data-mode="file">Files</button>
<button type="button" class="mode-btn" data-mode="link">YouTube Link</button>
</div>
<!-- Text mode -->
<div id="summarize-text-mode" class="summarize-mode">
<form id="summarize-text-form">
<div class="form-group">
<label for="summarize-input">Text to summarize</label>
<textarea id="summarize-input" rows="8" placeholder="Enter text to summarize..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="summarize-style">Style</label>
<select id="summarize-style">
<option value="concise">Concise</option>
<option value="detailed">Detailed</option>
<option value="bullet">Bullet Points</option>
</select>
</div>
<div class="form-group">
<label for="summarize-language">Output Language</label>
<select id="summarize-language">
<option value="same">Same as input</option>
<option value="en">English</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="de">German</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="zh">Chinese</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="ru">Russian</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary" id="summarize-text-btn">
<span class="btn-text">Summarize</span>
<span class="btn-loading">Summarizing...</span>
</button>
</form>
<div id="summarize-text-result" class="result"></div>
</div>
<!-- File mode -->
<div id="summarize-file-mode" class="summarize-mode" style="display: none;">
<div id="summarize-drop-zone" class="drop-zone">
<div class="drop-zone-content">
<div class="drop-zone-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p class="drop-zone-text">Drag & drop text files here</p>
<p class="drop-zone-hint">or click to select files (.txt)</p>
<input type="file" id="summarize-file-input" multiple accept=".txt,text/plain" style="display: none;">
</div>
</div>
<div id="summarize-selected-files" class="selected-files" style="display: none;">
<h3>Selected Files</h3>
<ul id="summarize-files-list"></ul>
<button type="button" id="summarize-clear-files" class="btn btn-small btn-secondary">Clear</button>
</div>
<form id="summarize-file-form">
<div class="form-row">
<div class="form-group">
<label for="summarize-file-style">Style</label>
<select id="summarize-file-style">
<option value="concise">Concise</option>
<option value="detailed">Detailed</option>
<option value="bullet">Bullet Points</option>
</select>
</div>
<div class="form-group">
<label for="summarize-file-language">Output Language</label>
<select id="summarize-file-language">
<option value="same">Same as input</option>
<option value="en">English</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="de">German</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="zh">Chinese</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="ru">Russian</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary" id="summarize-file-btn" disabled>
<span class="btn-text">Summarize Files</span>
<span class="btn-loading">Summarizing...</span>
</button>
</form>
<div id="summarize-file-result" class="result"></div>
</div>
<!-- Link mode (YouTube) -->
<div id="summarize-link-mode" class="summarize-mode" style="display: none;">
<form id="summarize-link-form">
<div class="form-group">
<label for="summarize-url">YouTube URL (video or playlist)</label>
<input type="url" id="summarize-url" placeholder="https://youtube.com/watch?v=..." required>
</div>
<div class="form-row">
<div class="form-group">
<label for="summarize-link-style">Summary Style</label>
<select id="summarize-link-style">
<option value="concise">Concise</option>
<option value="detailed">Detailed</option>
<option value="bullet">Bullet Points</option>
</select>
</div>
<div class="form-group">
<label for="summarize-link-language">Output Language</label>
<select id="summarize-link-language">
<option value="same">Same as video</option>
<option value="en">English</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="de">German</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="zh">Chinese</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="ru">Russian</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary" id="summarize-link-btn">
<span class="btn-text">Download + Transcribe + Summarize</span>
<span class="btn-loading">Processing...</span>
</button>
</form>
<div id="summarize-link-progress" class="progress-container" style="display: none;">
<div class="progress-header">
<span id="summarize-link-progress-title">Processing...</span>
<span id="summarize-link-progress-eta"></span>
</div>
<div class="progress-bar">
<div id="summarize-link-progress-fill" class="progress-fill"></div>
</div>
<div class="progress-details">
<span id="summarize-link-progress-percent">0%</span>
<span id="summarize-link-progress-phase"></span>
<span id="summarize-link-progress-speed"></span>
</div>
<div id="summarize-link-progress-current" class="progress-current"></div>
</div>
<div id="summarize-link-result" class="result"></div>
</div>
</section>
</div>
<script src="app.js"></script>

View File

@ -683,3 +683,27 @@ textarea::placeholder {
color: #ccd6f6;
line-height: 1.6;
}
/* Summarize Tab */
.summarize-mode-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.summarize-mode {
animation: fadeIn 0.3s ease;
}
/* Summary Result */
.summary-output {
margin-top: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
color: #ccd6f6;
line-height: 1.6;
}

View File

@ -3,10 +3,12 @@ import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import multer from 'multer';
import { download, getInfo } from './services/youtube.js';
import { transcribeFile, transcribeAndSave, transcribeMultiple } from './services/transcription.js';
import { translateText, translateFile, translateMultiple, getLanguages } from './services/translation.js';
import { summarizeText, summarizeFile, getSummaryStyles } from './services/summarize.js';
dotenv.config();
@ -58,8 +60,18 @@ const uploadText = multer({
app.use(cors());
app.use(express.json());
// Set permissive CSP for development
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
);
next();
});
// Serve static files (HTML interface)
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, '../public')));
// Serve downloaded files
@ -733,14 +745,302 @@ app.post('/translate-file', uploadText.array('files', 50), async (req, res) => {
}
});
/**
* GET /summary-styles
* Get available summary styles
*/
app.get('/summary-styles', (req, res) => {
res.json({ styles: getSummaryStyles() });
});
/**
* POST /summarize
* Summarize text using GPT-5.1
* Body: { text: string, style?: string, language?: string, model?: string }
*/
app.post('/summarize', async (req, res) => {
try {
const { text, style = 'concise', language = 'same', model = 'gpt-5.1' } = req.body;
if (!text) {
return res.status(400).json({ error: 'text required in request body' });
}
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
}
console.log(`Summarizing text with ${model} (style: ${style})`);
const result = await summarizeText(text, { style, language, model });
res.json({
success: true,
...result,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* POST /summarize-file
* Summarize uploaded text files using GPT-5.1
*/
app.post('/summarize-file', uploadText.array('files', 50), async (req, res) => {
try {
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const { style = 'concise', language = 'same', model = 'gpt-5.1' } = req.body;
const results = [];
console.log(`Summarizing ${req.files.length} files with ${model}`);
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
console.log(`[${i + 1}/${req.files.length}] Summarizing: ${file.originalname}`);
try {
const result = await summarizeFile(file.path, { style, language, model });
results.push({
success: true,
fileName: file.originalname,
summaryPath: result.summaryPath,
summaryUrl: `/files/${path.basename(result.summaryPath)}`,
summary: result.summary,
model: result.model,
chunks: result.chunks,
});
} catch (error) {
console.error(`Failed to summarize ${file.originalname}: ${error.message}`);
results.push({
success: false,
fileName: file.originalname,
error: error.message,
});
}
}
res.json({
success: true,
totalFiles: req.files.length,
successCount: results.filter(r => r.success).length,
failCount: results.filter(r => !r.success).length,
results,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /summarize-stream
* Full pipeline: Download -> Transcribe -> Summarize with SSE progress
* Query: url, style?, language?, model?
*/
app.get('/summarize-stream', async (req, res) => {
const { url, style = 'concise', language = 'same', model = 'gpt-4o-transcribe' } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter required' });
}
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OPENAI_API_KEY not configured' });
}
// Set up SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
const sendEvent = (event, data) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
const startTime = Date.now();
let totalVideos = 1;
try {
// Phase 1: Get info
sendEvent('status', { message: 'Fetching video info...', phase: 'info', percent: 0 });
const hasPlaylist = url.includes('list=');
const info = await getInfo(url, hasPlaylist);
totalVideos = info._type === 'playlist' ? (info.entries?.length || 1) : 1;
sendEvent('info', {
title: info.title,
type: info._type || 'video',
totalVideos,
playlistTitle: info._type === 'playlist' ? info.title : null,
});
// Phase 2: Download (0-33%)
sendEvent('status', { message: 'Downloading...', phase: 'downloading', percent: 5 });
console.log(`[Summarize Pipeline] Downloading: ${url}`);
let videosDownloaded = 0;
const downloadResult = await download(url, {
outputDir: OUTPUT_DIR,
onDownloadProgress: (progress) => {
const videoProgress = progress.percent || 0;
const overallPercent = ((videosDownloaded + (videoProgress / 100)) / totalVideos) * 33;
sendEvent('progress', {
percent: Math.round(overallPercent * 10) / 10,
phase: 'downloading',
phaseLabel: 'Downloading',
title: progress.title,
speed: progress.speed,
currentVideo: progress.videoIndex || videosDownloaded + 1,
totalVideos,
});
},
onVideoComplete: (video) => {
videosDownloaded++;
sendEvent('video-complete', {
title: video.title,
phase: 'downloading',
videosCompleted: videosDownloaded,
totalVideos,
});
},
});
// Phase 3: Transcribe (33-66%)
sendEvent('status', { message: 'Transcribing...', phase: 'transcribing', percent: 33 });
console.log(`[Summarize Pipeline] Transcribing ${downloadResult.successCount} files`);
const successfulDownloads = downloadResult.videos.filter(v => v.success);
const transcribeResults = [];
for (let i = 0; i < successfulDownloads.length; i++) {
const video = successfulDownloads[i];
const percent = 33 + ((i / successfulDownloads.length) * 33);
sendEvent('progress', {
percent: Math.round(percent * 10) / 10,
phase: 'transcribing',
phaseLabel: 'Transcribing',
title: video.title,
currentVideo: i + 1,
totalVideos: successfulDownloads.length,
});
try {
const result = await transcribeAndSave(video.filePath, {
responseFormat: 'text',
outputFormat: 'txt',
model,
});
transcribeResults.push({ ...result, title: video.title, success: true });
sendEvent('transcribe-complete', {
title: video.title,
success: true,
videosCompleted: i + 1,
totalVideos: successfulDownloads.length,
});
} catch (error) {
transcribeResults.push({ title: video.title, success: false, error: error.message });
sendEvent('transcribe-complete', {
title: video.title,
success: false,
error: error.message,
});
}
}
// Phase 4: Summarize (66-100%)
sendEvent('status', { message: 'Summarizing with GPT-5.1...', phase: 'summarizing', percent: 66 });
console.log(`[Summarize Pipeline] Summarizing ${transcribeResults.filter(t => t.success).length} transcriptions`);
const summaryResults = [];
const successfulTranscriptions = transcribeResults.filter(t => t.success);
for (let i = 0; i < successfulTranscriptions.length; i++) {
const transcription = successfulTranscriptions[i];
const percent = 66 + ((i / successfulTranscriptions.length) * 34);
sendEvent('progress', {
percent: Math.round(percent * 10) / 10,
phase: 'summarizing',
phaseLabel: 'Summarizing',
title: transcription.title,
currentVideo: i + 1,
totalVideos: successfulTranscriptions.length,
});
try {
const result = await summarizeFile(transcription.transcriptionPath, { style, language, model: 'gpt-5.1' });
summaryResults.push({
title: transcription.title,
success: true,
summary: result.summary,
summaryPath: result.summaryPath,
summaryUrl: `/files/${path.basename(result.summaryPath)}`,
transcriptionUrl: `/files/${path.basename(transcription.transcriptionPath)}`,
audioUrl: transcription.filePath ? `/files/${path.basename(transcription.filePath)}` : null,
});
sendEvent('summarize-complete', {
title: transcription.title,
success: true,
videosCompleted: i + 1,
totalVideos: successfulTranscriptions.length,
});
} catch (error) {
summaryResults.push({
title: transcription.title,
success: false,
error: error.message,
transcriptionUrl: `/files/${path.basename(transcription.transcriptionPath)}`,
});
sendEvent('summarize-complete', {
title: transcription.title,
success: false,
error: error.message,
});
}
}
// Final result
sendEvent('complete', {
success: true,
playlistTitle: downloadResult.playlistTitle,
totalVideos: downloadResult.totalVideos,
downloadedCount: downloadResult.successCount,
transcribedCount: transcribeResults.filter(t => t.success).length,
summarizedCount: summaryResults.filter(s => s.success).length,
totalTime: Math.round((Date.now() - startTime) / 1000),
results: summaryResults,
});
} catch (error) {
console.error(`[Summarize Pipeline] Error: ${error.message}`);
sendEvent('error', { message: error.message });
} finally {
res.end();
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log('\nEndpoints:');
console.log(' GET /health - Health check');
console.log(' GET /info?url= - Get video/playlist info');
console.log(' POST /download - Download as MP3');
console.log(' POST /transcribe - Transcribe audio file');
console.log(' POST /process - Download + transcribe');
console.log(' GET /files-list - List downloaded files');
console.log(' GET /files/<name> - Serve downloaded files');
console.log(' GET /health - Health check');
console.log(' GET /info?url= - Get video/playlist info');
console.log(' POST /download - Download as MP3');
console.log(' POST /transcribe - Transcribe audio file');
console.log(' POST /process - Download + transcribe');
console.log(' POST /summarize - Summarize text (GPT-5.1)');
console.log(' POST /summarize-file - Summarize files (GPT-5.1)');
console.log(' GET /summarize-stream - Full pipeline: Download + Transcribe + Summarize');
console.log(' GET /files-list - List downloaded files');
console.log(' GET /files/<name> - Serve downloaded files');
});

193
src/services/summarize.js Normal file
View File

@ -0,0 +1,193 @@
import OpenAI from 'openai';
import fs from 'fs';
import path from 'path';
let openai = null;
// Max characters per chunk for summarization
const MAX_CHUNK_CHARS = 30000;
/**
* Get OpenAI client (lazy initialization)
*/
function getOpenAI() {
if (!openai) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return openai;
}
/**
* Summarize text using GPT-4o
*/
export async function summarizeText(text, options = {}) {
const {
model = 'gpt-5.1', // GPT-5.1 - latest OpenAI model (Nov 2025)
language = 'same', // 'same' = same as input, or specify language code
style = 'concise', // 'concise', 'detailed', 'bullet'
maxLength = null, // optional max length in words
} = options;
const client = getOpenAI();
let styleInstruction = '';
switch (style) {
case 'detailed':
styleInstruction = 'Provide a detailed summary that captures all important points and nuances.';
break;
case 'bullet':
styleInstruction = 'Provide the summary as bullet points, highlighting the key points.';
break;
case 'concise':
default:
styleInstruction = 'Provide a concise summary that captures the main points.';
}
let languageInstruction = '';
if (language === 'same') {
languageInstruction = 'Write the summary in the same language as the input text.';
} else {
languageInstruction = `Write the summary in ${language}.`;
}
let lengthInstruction = '';
if (maxLength) {
lengthInstruction = `Keep the summary under ${maxLength} words.`;
}
const systemPrompt = `You are an expert summarizer. ${styleInstruction} ${languageInstruction} ${lengthInstruction}
Focus on the most important information and main ideas. Be accurate and objective.`;
// Handle long texts by chunking
if (text.length > MAX_CHUNK_CHARS) {
return await summarizeLongText(text, { model, systemPrompt, style });
}
const response = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Please summarize the following text:\n\n${text}` },
],
temperature: 0.3,
});
return {
summary: response.choices[0].message.content,
model,
style,
inputLength: text.length,
chunks: 1,
};
}
/**
* Summarize long text by chunking and combining summaries
*/
async function summarizeLongText(text, options) {
const { model, systemPrompt, style } = options;
const client = getOpenAI();
// Split into chunks
const chunks = [];
let currentChunk = '';
const sentences = text.split(/(?<=[.!?。!?\n])\s*/);
for (const sentence of sentences) {
if ((currentChunk + sentence).length > MAX_CHUNK_CHARS && currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = sentence;
} else {
currentChunk += ' ' + sentence;
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
console.log(`Summarizing ${chunks.length} chunks...`);
// Summarize each chunk
const chunkSummaries = [];
for (let i = 0; i < chunks.length; i++) {
console.log(`[${i + 1}/${chunks.length}] Summarizing chunk...`);
const response = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Please summarize the following text (part ${i + 1} of ${chunks.length}):\n\n${chunks[i]}` },
],
temperature: 0.3,
});
chunkSummaries.push(response.choices[0].message.content);
}
// Combine summaries if multiple chunks
if (chunkSummaries.length === 1) {
return {
summary: chunkSummaries[0],
model,
style,
inputLength: text.length,
chunks: 1,
};
}
// Create final combined summary
const combinedText = chunkSummaries.join('\n\n---\n\n');
const finalResponse = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: `You are an expert summarizer. Combine and synthesize the following partial summaries into a single coherent ${style} summary. Remove redundancy and ensure a smooth flow.` },
{ role: 'user', content: `Please combine these summaries into one:\n\n${combinedText}` },
],
temperature: 0.3,
});
return {
summary: finalResponse.choices[0].message.content,
model,
style,
inputLength: text.length,
chunks: chunks.length,
};
}
/**
* Summarize a text file
*/
export async function summarizeFile(filePath, options = {}) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const text = fs.readFileSync(filePath, 'utf-8');
const result = await summarizeText(text, options);
// Save summary to file
const dir = path.dirname(filePath);
const baseName = path.basename(filePath, path.extname(filePath));
const summaryPath = path.join(dir, `${baseName}_summary.txt`);
fs.writeFileSync(summaryPath, result.summary, 'utf-8');
return {
...result,
filePath,
summaryPath,
};
}
/**
* Get available summary styles
*/
export function getSummaryStyles() {
return {
concise: 'A brief summary capturing main points',
detailed: 'A comprehensive summary with nuances',
bullet: 'Key points as bullet points',
};
}

View File

@ -1,6 +1,73 @@
import youtubedl from 'youtube-dl-exec';
import { createRequire } from 'module';
import path from 'path';
import fs from 'fs';
import { spawn } from 'child_process';
// Use system yt-dlp binary (check common paths)
const YTDLP_PATH = process.env.YTDLP_PATH || 'yt-dlp';
/**
* Execute yt-dlp command and return parsed JSON
*/
async function ytdlp(url, args = []) {
return new Promise((resolve, reject) => {
const proc = spawn(YTDLP_PATH, [...args, url]);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data; });
proc.stderr.on('data', (data) => { stderr += data; });
proc.on('close', (code) => {
if (code === 0) {
try {
resolve(JSON.parse(stdout));
} catch {
resolve(stdout);
}
} else {
reject(new Error(stderr || `yt-dlp exited with code ${code}`));
}
});
});
}
/**
* Execute yt-dlp command with progress callback
*/
function ytdlpExec(url, args = [], onProgress) {
return new Promise((resolve, reject) => {
const proc = spawn(YTDLP_PATH, [...args, url]);
let stderr = '';
proc.stdout.on('data', (data) => {
const line = data.toString();
if (onProgress) {
const progressMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/);
const etaMatch = line.match(/ETA\s+(\d+:\d+)/);
const speedMatch = line.match(/at\s+([\d.]+\w+\/s)/);
if (progressMatch) {
onProgress({
percent: parseFloat(progressMatch[1]),
eta: etaMatch ? etaMatch[1] : null,
speed: speedMatch ? speedMatch[1] : null,
});
}
}
});
proc.stderr.on('data', (data) => { stderr += data; });
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(stderr || `yt-dlp exited with code ${code}`));
}
});
});
}
const OUTPUT_DIR = process.env.OUTPUT_DIR || './output';
@ -47,12 +114,12 @@ export async function getInfo(url, forcePlaylist = false) {
const playlistUrl = extractPlaylistUrl(url);
const targetUrl = (forcePlaylist && playlistUrl) ? playlistUrl : url;
const info = await youtubedl(targetUrl, {
dumpSingleJson: true,
noDownload: true,
noWarnings: true,
flatPlaylist: true,
});
const info = await ytdlp(targetUrl, [
'--dump-single-json',
'--no-download',
'--no-warnings',
'--flat-playlist',
]);
return info;
} catch (error) {
throw new Error(`Failed to get info: ${error.message}`);
@ -80,47 +147,32 @@ export async function downloadVideo(url, options = {}) {
try {
// Get video info first
const info = await youtubedl(url, {
dumpSingleJson: true,
noDownload: true,
noWarnings: true,
});
const info = await ytdlp(url, [
'--dump-single-json',
'--no-download',
'--no-warnings',
]);
const title = sanitizeFilename(info.title);
const outputPath = path.join(outputDir, `${title}.mp3`);
// Download and convert to MP3 with progress
const subprocess = youtubedl.exec(url, {
extractAudio: true,
audioFormat: 'mp3',
audioQuality: 0,
output: outputPath,
noWarnings: true,
newline: true,
await ytdlpExec(url, [
'--extract-audio',
'--audio-format', 'mp3',
'--audio-quality', '0',
'-o', outputPath,
'--no-warnings',
'--newline',
], (progress) => {
if (onDownloadProgress) {
onDownloadProgress({
...progress,
title: info.title,
});
}
});
// Parse progress from yt-dlp output
if (onDownloadProgress && subprocess.stdout) {
subprocess.stdout.on('data', (data) => {
const line = data.toString();
// Parse progress: [download] 45.2% of 10.5MiB at 1.2MiB/s ETA 00:05
const progressMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/);
const etaMatch = line.match(/ETA\s+(\d+:\d+)/);
const speedMatch = line.match(/at\s+([\d.]+\w+\/s)/);
if (progressMatch) {
onDownloadProgress({
percent: parseFloat(progressMatch[1]),
eta: etaMatch ? etaMatch[1] : null,
speed: speedMatch ? speedMatch[1] : null,
title: info.title,
});
}
});
}
await subprocess;
return {
success: true,
title: info.title,